das-cli 1.0.0__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.
das/cli.py ADDED
@@ -0,0 +1,1070 @@
1
+ import sys
2
+ import click
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from das.common.config import (
7
+ save_api_url, load_api_url, DEFAULT_BASE_URL,
8
+ save_verify_ssl, load_verify_ssl, VERIFY_SSL,
9
+ load_openai_api_key, save_openai_api_key, clear_openai_api_key,
10
+ clear_token, _config_dir
11
+ )
12
+
13
+ from das.app import Das
14
+ from das.managers.download_manager import DownloadManager
15
+ from das.managers.entries_manager import EntryManager
16
+ from das.managers.search_manager import SearchManager
17
+ from das.common.file_utils import load_file_based_on_extension, parse_data_string
18
+ from das.ai.plugins.dasai import DasAI
19
+
20
+ # Simple table formatting function to avoid external dependencies
21
+ def format_table(data, headers):
22
+ """Format data into a text-based table without external dependencies"""
23
+ if not data:
24
+ return "No data to display"
25
+
26
+ # Calculate column widths
27
+ col_widths = [max(len(str(h)), max([len(str(row[i])) for row in data] or [0])) for i, h in enumerate(headers)]
28
+
29
+ # Create the separator line
30
+ separator = '+' + '+'.join('-' * (w + 2) for w in col_widths) + '+'
31
+
32
+ # Create the header row
33
+ header_row = '|' + '|'.join(' ' + str(h).ljust(w) + ' ' for h, w in zip(headers, col_widths)) + '|'
34
+
35
+ # Create the data rows
36
+ data_rows = []
37
+ for row in data:
38
+ data_rows.append('|' + '|'.join(' ' + str(cell).ljust(w) + ' ' for cell, w in zip(row, col_widths)) + '|')
39
+
40
+ # Assemble the table
41
+ table = [separator, header_row, separator]
42
+ table.extend(data_rows)
43
+ table.append(separator)
44
+
45
+ return '\n'.join(table)
46
+
47
+ class DasCLI:
48
+ def __init__(self):
49
+ self.client = None
50
+ self.api_url = None
51
+ self.entry_manager = None
52
+ self.search_manager = None
53
+ self.download_manager = None
54
+ self.das_ai = None
55
+
56
+ def get_client(self):
57
+ """Get DAS client instance, using saved API URL"""
58
+ if not self.api_url:
59
+ self.api_url = load_api_url() or DEFAULT_BASE_URL
60
+ if not self.api_url:
61
+ raise click.UsageError(
62
+ "No API URL configured. Please login first with 'das login --api-url <URL>'"
63
+ )
64
+
65
+ self.entry_manager = EntryManager()
66
+ self.search_manager = SearchManager()
67
+ self.download_manager = DownloadManager()
68
+ # Set SSL verification based on saved config
69
+ if not self.client:
70
+ self.client = Das(self.api_url)
71
+
72
+ return self.client
73
+
74
+ def get_das_ai(self):
75
+ """Get DAS AI instance"""
76
+ if not self.das_ai:
77
+ self.das_ai = DasAI()
78
+ return self.das_ai
79
+
80
+ pass_das_context = click.make_pass_decorator(DasCLI, ensure=True)
81
+
82
+ @click.group()
83
+ @click.pass_context
84
+ def cli(ctx):
85
+ """DAS Python CLI - Data Archive System client tool"""
86
+ ctx.obj = DasCLI()
87
+
88
+ @cli.command()
89
+ @click.option('--api-url', required=True, help='API base URL')
90
+ @click.option('--username', required=True, prompt=True, help='Username')
91
+ @click.option('--password', required=True, prompt=True, hide_input=True, help='Password')
92
+ @pass_das_context
93
+ def login(das_ctx, api_url, username, password):
94
+ """Login and store authentication token"""
95
+ # Save API URL for future use
96
+ save_api_url(api_url)
97
+ das_ctx.api_url = api_url
98
+
99
+ # Authenticate
100
+ client = das_ctx.get_client()
101
+ token = client.authenticate(username, password)
102
+ if not token:
103
+ click.secho("❌ Authentication failed. Please check your credentials.", fg="red")
104
+ return
105
+ else:
106
+ click.secho("✓ Authentication successful!", fg="green")
107
+
108
+ # Search commands group
109
+ @cli.group()
110
+ def search():
111
+ """Commands for searching entries"""
112
+ pass
113
+
114
+ @search.command("help")
115
+ def search_help():
116
+ """Show help about search query syntax and formats"""
117
+ click.secho("\nSearch Query Syntax Help", fg="green", bold=True)
118
+ click.echo("=" * 50)
119
+
120
+ click.secho("\nBasic Search:", fg="blue", bold=True)
121
+ click.echo(" das search entries --attribute <AttributeName> --query '<SearchQuery>'")
122
+ click.echo(" Example: das search entries --attribute Cores --query 'name(*64*)'")
123
+
124
+ click.secho("\nOutput Formats:", fg="blue", bold=True)
125
+ click.echo(" --format table Format results in a nice table (default)")
126
+ click.echo(" --format json Return results as JSON")
127
+ click.echo(" --format compact Simple one-line-per-result format")
128
+ click.echo(" --raw Show raw API response")
129
+
130
+ click.secho("\nQuery Syntax Examples:", fg="blue", bold=True)
131
+ click.echo(" 'name(*pattern*)' Search for pattern in name")
132
+ click.echo(" 'name(*pattern*);code(*ABC*)' Multiple conditions (AND)")
133
+ click.echo(" 'Create at(>2023-01-01)' Date comparison")
134
+
135
+ click.secho("\nGet Detailed Information:", fg="blue", bold=True)
136
+ click.echo(" das search entry <ID> Get detailed info for a single entry")
137
+ click.echo(" Example: das search entry 6b0e68e6-00cd-43a7-9c51-d56c9c091123")
138
+
139
+ click.secho("\nPagination:", fg="blue", bold=True)
140
+ click.echo(" --page <num> Show specific page of results")
141
+ click.echo(" --max-results <n> Number of results per page (default: 10)")
142
+
143
+ click.secho("\nSorting:", fg="blue", bold=True)
144
+ click.echo(" --sort-by <field> Field to sort by (default: Name)")
145
+ click.echo(" --sort-order <order> Sort order: 'asc' or 'desc' (default: asc)")
146
+ click.echo("\n")
147
+
148
+ @search.command("entries")
149
+ @click.option('--attribute', required=True, help='Attribute name to search in')
150
+ @click.option('--query', required=True, help='Search query string')
151
+ @click.option('--max-results', default=10, help='Maximum number of results to return')
152
+ @click.option('--page', default=1, help='Page number for paginated results')
153
+ @click.option('--sort-by', default='Name', help='Field to sort by (default: Name)')
154
+ @click.option('--sort-order', default='asc', type=click.Choice(['asc', 'desc']), help='Sort order (asc or desc)')
155
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json', 'compact']), help='Output format (table, json, or compact)')
156
+ @click.option('--raw', is_flag=True, help='Show raw response from API')
157
+ @pass_das_context
158
+ def search_entries(das_ctx, attribute, query, max_results, page, sort_by, sort_order, output_format, raw):
159
+ """Search entries based on attribute and query"""
160
+ try:
161
+ # Ensure client and search_manager are initialized
162
+ das_ctx.get_client()
163
+
164
+ results = das_ctx.search_manager.search_entries(
165
+ attribute=attribute,
166
+ query=query,
167
+ max_results=max_results,
168
+ page=page,
169
+ sort_by=sort_by,
170
+ sort_order=sort_order
171
+ )
172
+
173
+ if raw:
174
+ click.echo(results)
175
+ return
176
+
177
+ total_count = results.get('totalCount', 0)
178
+ items = results.get('items', [])
179
+
180
+ # Display search summary
181
+ click.secho(f"\nFound {total_count} results for query: '{query}' in attribute: '{attribute}'", fg="blue")
182
+ click.secho(f"Showing results {(page-1)*max_results + 1}-{min(page*max_results, total_count)} of {total_count}\n", fg="blue")
183
+
184
+ if not items:
185
+ click.secho("No results found.", fg="yellow")
186
+ return
187
+
188
+ if output_format == 'json':
189
+ click.echo(json.dumps(results, indent=2))
190
+ elif output_format == 'compact':
191
+ for i, item in enumerate(items, 1):
192
+ entry = item.get('entry', {})
193
+ code = entry.get('code', 'N/A')
194
+ name = entry.get('displayname', 'Unnamed')
195
+ description = entry.get('description', 'No description')
196
+ click.echo(f"{i}. {name} (Code: {code}) - {description}")
197
+ else: # table format
198
+ headers = ["#", "Code", "Name", "Description", "Owner"]
199
+ table_data = []
200
+
201
+ for i, item in enumerate(items, 1):
202
+ entry = item.get('entry', {})
203
+ table_data.append([
204
+ i,
205
+ entry.get('code', 'N/A'),
206
+ entry.get('displayname', 'Unnamed'),
207
+ entry.get('description', 'No description')[:50] + ('...' if entry.get('description', '') and len(entry.get('description', '')) > 50 else ''),
208
+ entry.get('owner', 'Unknown')
209
+ ])
210
+
211
+ click.echo(format_table(table_data, headers))
212
+
213
+ except Exception as e:
214
+ click.secho(f"Error: {e}", fg="red")
215
+
216
+ # Hangfire commands group
217
+ @search.command("entry")
218
+ @click.argument('entry_id', required=True)
219
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
220
+ @pass_das_context
221
+ def get_entry(das_ctx, entry_id, output_format):
222
+ """Get detailed information about a single entry by ID"""
223
+ try:
224
+ # Reusing the entry manager to get entry details by ID
225
+ entry = das_ctx.entry_manager.get_entry(entry_id)
226
+
227
+ if output_format == 'json':
228
+ click.echo(json.dumps(entry, indent=2))
229
+ else:
230
+ click.secho(f"\nEntry Details (ID: {entry_id})", fg="blue", bold=True)
231
+ click.echo("=" * 50)
232
+
233
+ if not entry or not isinstance(entry, dict):
234
+ click.secho("No entry found with the specified ID.", fg="yellow")
235
+ return
236
+
237
+ # Format display fields in a readable way
238
+ formatted_data = []
239
+ for key, value in sorted(entry.items()):
240
+ # Skip empty or None values
241
+ if value is None or value == '':
242
+ continue
243
+
244
+ # Format date fields
245
+ if isinstance(value, str) and key.lower().endswith(('time', 'date')) and 'T' in value:
246
+ try:
247
+ date_part = value.split('T')[0]
248
+ time_part = value.split('T')[1].split('.')[0]
249
+ value = f"{date_part} {time_part}"
250
+ except:
251
+ pass # Keep original if formatting fails
252
+
253
+ # Add to formatted data
254
+ formatted_data.append((key, value))
255
+
256
+ # Display as key-value pairs
257
+ for key, value in formatted_data:
258
+ click.echo(f"{key.ljust(25)}: {value}")
259
+
260
+ except Exception as e:
261
+ click.secho(f"Error: {e}", fg="red")
262
+
263
+ @cli.group()
264
+ def hangfire():
265
+ """Commands for working with Hangfire tasks"""
266
+ pass
267
+
268
+ @hangfire.command("sync-doi")
269
+ @click.argument('id', required=True)
270
+ @pass_das_context
271
+ def sync_doi(das_ctx, id):
272
+ """Trigger a DOI synchronization task by ID"""
273
+ client = das_ctx.get_client()
274
+ try:
275
+ client.hangfire.sync_doi(id)
276
+ click.secho(f"✓ DOI synchronization task triggered!", fg="green")
277
+ except Exception as e:
278
+ click.secho(f"Error: {e}", fg="red")
279
+
280
+ # Entries commands group
281
+ @cli.group()
282
+ def entry():
283
+ """Commands for working with entries"""
284
+ pass
285
+
286
+ @entry.command("update")
287
+ @click.option('--attribute', required=True, help='Attribute name')
288
+ @click.option('--code', help='Entry code to update (only used when --data is provided)')
289
+ @click.argument('file_path', required=False)
290
+ @click.option('--data', help='Data string in format { "key1": "value1", "key2": "value2", ... } or a list of such objects')
291
+ @pass_das_context
292
+ def update_entry(das_ctx, attribute, code=None, file_path=None, data=None):
293
+ """Update entries from file or data string
294
+
295
+ Each entry to update must contain a Code field that identifies the entry.
296
+ Files can contain multiple entries (rows in CSV/XLS or list of objects in JSON).
297
+
298
+ Examples:
299
+
300
+ \b
301
+ # Update entries from JSON file
302
+ das entry update --attribute core c:\\data\\entries.json
303
+
304
+ \b
305
+ # Update entries from CSV file
306
+ das entry update --attribute core c:\\data\\entries.csv
307
+
308
+ \b
309
+ # Update entries from Excel file
310
+ das entry update --attribute core c:\\data\\entries.xls
311
+
312
+ \b
313
+ # Update a single entry from data string
314
+ das entry update --attribute core --code ENT001 --data { 'Grant Public Access': Yes, ... }
315
+
316
+ \b
317
+ # Update multiple entries from data string
318
+ das entry update --attribute core --data [{ 'Code': 'ENT001', ... }, { 'Code': 'ENT002', ... }]
319
+ """
320
+ try:
321
+ if not file_path and not data:
322
+ raise click.UsageError("Please provide either a file path or data string")
323
+
324
+ entry_data = None
325
+ is_bulk_update = False
326
+
327
+ if file_path:
328
+ click.echo(f"Loading data from file: {file_path}")
329
+ entry_data = load_file_based_on_extension(file_path)
330
+
331
+ # Check if we got a list or a single object
332
+ if isinstance(entry_data, list):
333
+ is_bulk_update = True
334
+ click.echo(f"Found {len(entry_data)} entries to update")
335
+ else:
336
+ # If we got a single object but no code was provided, check if it has a Code field
337
+ if not code:
338
+ # Look for Code field (case-insensitive)
339
+ entry_code = next((entry_data.get(key) for key in entry_data if key.lower() == 'code'), None)
340
+ if not entry_code:
341
+ raise click.UsageError("No code provided and entry data doesn't contain a Code field")
342
+ code = entry_code
343
+
344
+ elif data:
345
+ click.echo("Parsing data string")
346
+ entry_data = parse_data_string(data)
347
+
348
+ # Check if we got a list or a single object
349
+ if isinstance(entry_data, list):
350
+ is_bulk_update = True
351
+ click.echo(f"Found {len(entry_data)} entries to update")
352
+ elif not code:
353
+ # If we got a single object but no code was provided, check if it has a Code field
354
+ entry_code = next((entry_data.get(key) for key in entry_data if key.lower() == 'code'), None)
355
+ if not entry_code:
356
+ raise click.UsageError("No code provided and data doesn't contain a Code field")
357
+ code = entry_code
358
+
359
+ if not entry_data:
360
+ raise click.UsageError("No valid entry data found")
361
+
362
+ # Update the entries
363
+ if is_bulk_update:
364
+ results = das_ctx.entry_manager.update(attribute=attribute, entries=entry_data)
365
+
366
+ # Display results
367
+ success_count = sum(1 for result in results if result.get('status') == 'success')
368
+ error_count = len(results) - success_count
369
+
370
+ if success_count > 0:
371
+ click.secho(f"✓ Successfully updated {success_count} entries", fg="green")
372
+
373
+ if error_count > 0:
374
+ click.secho(f"✗ Failed to update {error_count} entries", fg="red")
375
+
376
+ # Show details of the failures
377
+ click.echo("\nFailed updates:")
378
+ for result in results:
379
+ if result.get('status') == 'error':
380
+ click.echo(f" Code: {result.get('code', 'Unknown')}, Error: {result.get('error', 'Unknown error')}")
381
+ else:
382
+ # Single entry update
383
+ results = das_ctx.entry_manager.update(attribute=attribute, code=code, entry=entry_data)
384
+
385
+ if results and results[0].get('status') == 'success':
386
+ click.secho(f"✓ Entry '{code}' updated successfully!", fg="green")
387
+ click.echo(f"Entry ID: {results[0].get('id')}")
388
+ else:
389
+ error_msg = results[0].get('error', 'No response from server') if results else 'No response from server'
390
+ click.secho(f"Entry update failed: {error_msg}", fg="red")
391
+
392
+ except Exception as e:
393
+ click.secho(f"Error: {e}", fg="red")
394
+
395
+ @entry.command("delete")
396
+ @click.argument('code', required=True)
397
+ @pass_das_context
398
+ def delete_entry(das_ctx, code):
399
+ """Delete entry by its code"""
400
+ client = das_ctx.get_client()
401
+ try:
402
+ client.entries.delete(code)
403
+ click.secho(f"✓ Entry '{code}' deleted!", fg="green")
404
+ except Exception as e:
405
+ click.secho(f"Error: {e}", fg="red")
406
+
407
+ @entry.command("create")
408
+ @click.option('--attribute', required=True, help='Attribute name')
409
+ @click.argument('file_path', required=False)
410
+ @click.option('--data', help='Data string in format { "key1": "value1", "key2": "value2", ... } or a list of such objects')
411
+ @pass_das_context
412
+ def create_entry(das_ctx, attribute, file_path=None, data=None):
413
+ """Create one or more entries from file or data string
414
+
415
+ Files can contain multiple entries (rows in CSV/XLS or list of objects in JSON).
416
+
417
+ Examples:
418
+
419
+ \b
420
+ # Create entries from JSON file
421
+ das entry create --attribute core c:\\data\\entries.json
422
+
423
+ \b
424
+ # Create entries from CSV file
425
+ das entry create --attribute core c:\\data\\entries.csv
426
+
427
+ \b
428
+ # Create entries from Excel file
429
+ das entry create --attribute core c:\\data\\entries.xls
430
+
431
+ \b
432
+ # Create a single entry from data string
433
+ das entry create --attribute core --data { 'Grant Public Access': Yes, ... }
434
+
435
+ \b
436
+ # Create multiple entries from data string
437
+ das entry create --attribute core --data [{ 'Name': 'Entry 1', ... }, { 'Name': 'Entry 2', ... }]
438
+ """
439
+ try:
440
+ if not file_path and not data:
441
+ raise click.UsageError("Please provide either a file path or data string")
442
+
443
+ entry_data = None
444
+ is_bulk_create = False
445
+
446
+ if file_path:
447
+ click.echo(f"Loading data from file: {file_path}")
448
+ entry_data = load_file_based_on_extension(file_path)
449
+
450
+ # Check if we got a list or a single object
451
+ if isinstance(entry_data, list):
452
+ is_bulk_create = True
453
+ click.echo(f"Found {len(entry_data)} entries to create")
454
+
455
+ elif data:
456
+ click.echo("Parsing data string")
457
+ entry_data = parse_data_string(data)
458
+
459
+ # Check if we got a list or a single object
460
+ if isinstance(entry_data, list):
461
+ is_bulk_create = True
462
+ click.echo(f"Found {len(entry_data)} entries to create")
463
+
464
+ if not entry_data:
465
+ raise click.UsageError("No valid entry data found")
466
+
467
+ # Create the entries
468
+ if is_bulk_create:
469
+ results = das_ctx.entry_manager.create(attribute=attribute, entries=entry_data)
470
+
471
+ # Display results
472
+ success_count = sum(1 for result in results if result.get('status') == 'success')
473
+ error_count = len(results) - success_count
474
+
475
+ if success_count > 0:
476
+ click.secho(f"✓ Successfully created {success_count} entries", fg="green")
477
+
478
+ # Show IDs of created entries
479
+ click.echo("\nCreated entry IDs:")
480
+ for result in results:
481
+ if result.get('status') == 'success':
482
+ click.echo(f" {result.get('id')}")
483
+
484
+ if error_count > 0:
485
+ click.secho(f"✗ Failed to create {error_count} entries", fg="red")
486
+
487
+ # Show details of the failures
488
+ click.echo("\nFailed creations:")
489
+ for i, result in enumerate(results):
490
+ if result.get('status') == 'error':
491
+ click.echo(f" Entry #{i+1}: {result.get('error', 'Unknown error')}")
492
+ else:
493
+ # Single entry creation
494
+ results = das_ctx.entry_manager.create(attribute=attribute, entry=entry_data)
495
+
496
+ if results and results[0].get('status') == 'success':
497
+ click.secho(f"✓ Entry created successfully!", fg="green")
498
+ click.echo(f"Entry ID: {results[0].get('id')}")
499
+ else:
500
+ error_msg = results[0].get('error', 'No ID returned') if results else 'No ID returned'
501
+ click.secho(f"Entry creation failed: {error_msg}", fg="red")
502
+
503
+ except Exception as e:
504
+ click.secho(f"Error: {e}", fg="red")
505
+
506
+ @entry.command("get")
507
+ @click.option('--code', default=None, help='Entry code')
508
+ @click.option('--id', type=int, default=None, help='Entry ID')
509
+ @pass_das_context
510
+ def get_entry(das_ctx, code=None, id=None):
511
+ """Get entry by either its code or ID"""
512
+
513
+ if not code and not id:
514
+ raise click.UsageError("Please provide either an entry code or ID")
515
+
516
+ try:
517
+ # Pass client as a named parameter to avoid conflicts with 'id' parameter
518
+ entry = das_ctx.entry_manager.get(code=code, id=id)
519
+ if entry:
520
+ click.secho("Entry Details:", fg="green", bold=True)
521
+ click.echo("─" * 40)
522
+ for key, value in entry.items():
523
+ if key == 'Digital Object(s)' and isinstance(value, list):
524
+ continue
525
+ elif isinstance(value, list):
526
+ click.echo(f"{key}:")
527
+ for item in value:
528
+ click.echo(f" - {item}")
529
+ else:
530
+ click.echo(f"{key}: {value or ''}")
531
+
532
+ # if entry contains key: Digital Object(s) and its a list, print each object details
533
+ # with indentation for better readability
534
+ if 'Digital Object(s)' in entry and isinstance(entry['Digital Object(s)'], list):
535
+ click.echo()
536
+ click.echo("─" * 40)
537
+ click.echo("Digital Object(s):")
538
+ for obj in entry['Digital Object(s)']:
539
+ click.echo(f" - ID: {obj.get('Id')}")
540
+ click.echo(f" Name: {obj.get('Name')}")
541
+ click.echo(f" Type: {obj.get('Type')}")
542
+ click.echo(f" Links: {obj.get('Links')}")
543
+ click.echo()
544
+ click.echo("─" * 40)
545
+ else:
546
+ click.secho("Entry not found.", fg="yellow")
547
+ click.echo("Please check the entry code or ID.")
548
+
549
+ except Exception as e:
550
+ click.secho(f"Error: {e}", fg="red")
551
+
552
+ # Attribute commands group
553
+ @cli.group()
554
+ def attribute():
555
+ """Commands for working with attributes"""
556
+ pass
557
+
558
+ # Cache commands group
559
+ @cli.group()
560
+ def cache():
561
+ """Commands for working with cache"""
562
+ pass
563
+
564
+ # Config commands group
565
+ @cli.group()
566
+ def config():
567
+ """Commands for configuring the CLI"""
568
+ pass
569
+
570
+ @cache.command("clear-all")
571
+ @pass_das_context
572
+ def clear_all_cache(das_ctx):
573
+ """Clear all cache entries"""
574
+ client = das_ctx.get_client()
575
+ try:
576
+ result = client.cache.clear_all()
577
+ if result.get('success'):
578
+ click.secho("✓ All cache cleared!", fg="green")
579
+ else:
580
+ click.secho("Failed to clear cache.", fg="red")
581
+ except Exception as e:
582
+ click.secho(f"Error: {e}", fg="red")
583
+
584
+ @cache.command("list")
585
+ @pass_das_context
586
+ def list_cache(das_ctx):
587
+ """List all cache entries"""
588
+ client = das_ctx.get_client()
589
+ try:
590
+ caches = client.cache.get_all()
591
+ if caches:
592
+ click.secho("Cache Entries:", fg="green", bold=True)
593
+ click.echo("─" * 40)
594
+ # Sort cache items by name before displaying
595
+ sorted_caches = sorted(caches.get('items', []), key=lambda x: x.get('name', '').lower())
596
+ for cache in sorted_caches:
597
+ click.echo(f"Name: {cache.get('name')}")
598
+ click.echo("─" * 40)
599
+ else:
600
+ click.secho("No cache entries found.", fg="yellow")
601
+ except Exception as e:
602
+ click.secho(f"Error: {e}", fg="red")
603
+
604
+ @cache.command("clear")
605
+ @click.argument('name', required=True)
606
+ @pass_das_context
607
+ def clear_cache(das_ctx, name):
608
+ """Clear a specific cache by name"""
609
+ client = das_ctx.get_client()
610
+ try:
611
+ result = client.cache.clear_cache(name)
612
+ if result.get('success'):
613
+ click.secho(f"✓ Cache '{name}' cleared!", fg="green")
614
+ else:
615
+ click.secho(f"Failed to clear cache '{name}'.", fg="red")
616
+ except Exception as e:
617
+ click.secho(f"Error: {e}", fg="red")
618
+
619
+ @attribute.command("get")
620
+ @click.option('--id', type=int, default=None, help='Attribute ID')
621
+ @click.option('--name', default=None, help='Attribute name')
622
+ @click.option('--alias', default=None, help='Attribute alias')
623
+ @click.option('--table-name', default=None, help='Table name')
624
+ @pass_das_context
625
+ def get_attribute(das_ctx, id, name, alias, table_name):
626
+ """Get attribute by ID, name, alias, or table name"""
627
+ if not any([id, name, alias, table_name]):
628
+ raise click.UsageError("Please provide at least one search parameter")
629
+
630
+ client = das_ctx.get_client()
631
+ try:
632
+ result = client.attributes.get_attribute(id=id, name=name, alias=alias, table_name=table_name)
633
+ if result.get('success') and result.get('result', {}).get('items'):
634
+ attributes = result['result']['items']
635
+ for attr in attributes:
636
+ click.secho("\nAttribute Details:", fg="green", bold=True)
637
+ click.echo("─" * 40)
638
+ click.echo(f"ID: {attr.get('id')}")
639
+ click.echo(f"Name: {attr.get('name')}")
640
+ click.echo(f"Description: {attr.get('description')}")
641
+ click.echo(f"Alias: {attr.get('alias')}")
642
+ click.echo(f"Table Name: {attr.get('tableName')}")
643
+ click.echo(f"Menu Name: {attr.get('menuName')}")
644
+ click.echo(f"Context: {attr.get('contextName')}")
645
+ click.echo(f"Indexable: {attr.get('isIndexable')}")
646
+ if attr.get('indexName'):
647
+ click.echo(f"Index Name: {attr.get('indexName')}")
648
+ click.echo("─" * 40)
649
+ else:
650
+ click.secho("No attributes found.", fg="yellow")
651
+ except Exception as e:
652
+ click.secho(f"Error: {e}", fg="red")
653
+
654
+
655
+ @attribute.command("get-name")
656
+ @click.argument('id', type=int, required=True)
657
+ @pass_das_context
658
+ def get_attribute_name(das_ctx, id):
659
+ """Get attribute name by ID"""
660
+ client = das_ctx.get_client()
661
+ try:
662
+ name = client.attributes.get_name(id)
663
+ click.echo(name)
664
+ except Exception as e:
665
+ click.secho(f"Error: {e}", fg="red")
666
+
667
+ @attribute.command("get-id")
668
+ @click.argument('name', required=True)
669
+ @pass_das_context
670
+ def get_attribute_id(das_ctx, name):
671
+ """Get attribute ID by name"""
672
+ client = das_ctx.get_client()
673
+ try:
674
+ attr_id = client.attributes.get_id(name)
675
+ click.echo(attr_id)
676
+ except Exception as e:
677
+ click.secho(f"Error: {e}", fg="red")
678
+
679
+ # SSL verification commands
680
+ @config.command("ssl-verify")
681
+ @click.argument('enabled', type=click.Choice(['true', 'false']), required=True)
682
+ def set_ssl_verify(enabled):
683
+ """Set SSL certificate verification (true/false)"""
684
+ verify = enabled.lower() == 'true'
685
+ save_verify_ssl(verify)
686
+ status = "enabled" if verify else "disabled"
687
+ click.echo(f"SSL certificate verification {status}")
688
+
689
+ @config.command("ssl-status")
690
+ def get_ssl_status():
691
+ """Show current SSL certificate verification status"""
692
+ status = "enabled" if VERIFY_SSL else "disabled"
693
+ click.echo(f"SSL certificate verification is currently {status}")
694
+
695
+ @config.command("reset")
696
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
697
+ def reset_config(force):
698
+ """Clear all configuration files (token, URL, and authentication info)"""
699
+ if not force:
700
+ if not click.confirm("This will remove all saved credentials and configuration. Are you sure?"):
701
+ click.echo("Operation cancelled.")
702
+ return
703
+
704
+ from das.common.config import clear_token, _config_dir
705
+ import shutil
706
+
707
+ # Clear token (handles both keyring and file-based storage)
708
+ clear_token()
709
+
710
+ # Get the config directory path
711
+ config_dir = _config_dir()
712
+
713
+ # Check if the directory exists
714
+ if config_dir.exists():
715
+ # Remove all files in the config directory
716
+ for file_path in config_dir.glob("*"):
717
+ if file_path.is_file():
718
+ try:
719
+ file_path.unlink()
720
+ click.echo(f"Removed: {file_path.name}")
721
+ except Exception as e:
722
+ click.secho(f"Failed to remove {file_path.name}: {e}", fg="red")
723
+
724
+ click.secho("✓ All configuration files and credentials have been removed.", fg="green")
725
+
726
+ # Download commands group
727
+ @cli.group()
728
+ def download():
729
+ """Commands for working with downloads"""
730
+ pass
731
+
732
+ @download.command("request")
733
+ @click.option('--name', help='Name for the download request (defaults to timestamp if not provided)')
734
+ @click.option('--entry', '-e', multiple=True, required=True, help='Entry code to download files from. Can be used multiple times.')
735
+ @click.option('--file', '-f', multiple=True, help='File codes to download. If not specified, all files will be downloaded.')
736
+ @click.option('--from-file', help='Load download request from a JSON file')
737
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
738
+ @pass_das_context
739
+ def create_download_request(das_ctx, name, entry, file, from_file, output_format):
740
+ """
741
+ Create a new download request for specified entries and files
742
+
743
+ Examples:
744
+
745
+ \b
746
+ # Download all files from an entry
747
+ das download request --entry ENT001
748
+
749
+ \b
750
+ # Download specific files from an entry
751
+ das download request --entry ENT001 --file FILE001 --file FILE002
752
+
753
+ \b
754
+ # Download from multiple entries
755
+ das download request --entry ENT001 --entry ENT002 --name "My Download"
756
+
757
+ \b
758
+ # Download specific files from multiple entries
759
+ das download request --entry ENT001 --file FILE001 --entry ENT002 --file FILE003
760
+
761
+ \b
762
+ # Download using a JSON file specification
763
+ das download request --from-file request.json
764
+ """
765
+ try:
766
+ request_data = {}
767
+
768
+ # Handle name parameter
769
+ if name:
770
+ request_data['name'] = name
771
+
772
+ # Handle from-file parameter
773
+ if from_file:
774
+ click.echo(f"Loading download request from file: {from_file}")
775
+ try:
776
+ with open(from_file, 'r') as f:
777
+ file_data = json.load(f)
778
+ request_data.update(file_data)
779
+ except Exception as e:
780
+ raise click.UsageError(f"Failed to load file: {e}")
781
+ # Handle entry and file parameters
782
+ else:
783
+ # Group files by entry
784
+ current_entry = None
785
+ entry_files = {}
786
+
787
+ # If we have entries and files, we need to pair them correctly
788
+ if entry and file:
789
+ for arg in sys.argv:
790
+ if arg == '--entry' or arg == '-e':
791
+ # Next arg will be an entry code
792
+ current_entry = None
793
+ elif arg == '--file' or arg == '-f':
794
+ # Next arg will be a file code
795
+ pass
796
+ elif current_entry is None and arg in entry:
797
+ # This is an entry code
798
+ current_entry = arg
799
+ if current_entry not in entry_files:
800
+ entry_files[current_entry] = []
801
+ elif current_entry is not None and arg in file:
802
+ # This is a file code for the current entry
803
+ entry_files[current_entry].append(arg)
804
+ else:
805
+ # If we have entries but no files, download all files for each entry
806
+ for e in entry:
807
+ entry_files[e] = []
808
+
809
+ # Update request_data with the entry files
810
+ for e, files in entry_files.items():
811
+ request_data[e] = files
812
+
813
+ if not request_data or all(key == 'name' for key in request_data.keys()):
814
+ raise click.UsageError("No download request data provided")
815
+
816
+ # Execute the download request
817
+ # Make sure client and download_manager are initialized
818
+ das_ctx.get_client()
819
+ result = das_ctx.download_manager.create_download_request(request_data)
820
+
821
+ # Check if result contains errors
822
+ if isinstance(result, dict) and 'errors' in result:
823
+ click.secho("Download request failed with errors:", fg="red")
824
+ for error in result['errors']:
825
+ click.secho(f"- {error}", fg="red")
826
+ return
827
+
828
+ # Handle the result based on its type
829
+ if output_format == 'json':
830
+ # If result is a string (just the ID), wrap it in a dict for consistent JSON output
831
+ if isinstance(result, str):
832
+ click.echo(json.dumps({"id": result}, indent=2))
833
+ else:
834
+ click.echo(json.dumps(result, indent=2))
835
+ else:
836
+ click.secho("✓ Download request created successfully!", fg="green")
837
+
838
+ # If result is a string, it's just the request ID
839
+ if isinstance(result, str):
840
+ request_id = result
841
+ click.echo(f"Request ID: {request_id}")
842
+
843
+ # Show download instructions
844
+ click.echo("\nUse the following command to check the status of your download:")
845
+ click.secho(f" das download status {request_id}", fg="cyan")
846
+ else:
847
+ # Handle the case where result is a dictionary with more information
848
+ request_id = result.get('id', 'Unknown')
849
+ click.echo(f"Request ID: {request_id}")
850
+ click.echo(f"Status: {result.get('status', 'Pending')}")
851
+
852
+ # Display files in the request if available
853
+ if result.get('items'):
854
+ click.secho("\nFiles in this download request:", fg="blue")
855
+
856
+ headers = ["#", "File Name", "Entry", "Size", "Status"]
857
+ table_data = []
858
+
859
+ for i, item in enumerate(result.get('items', []), 1):
860
+ file_info = item.get('fileInfo', {})
861
+ table_data.append([
862
+ i,
863
+ file_info.get('name', 'Unknown'),
864
+ file_info.get('entryCode', 'Unknown'),
865
+ file_info.get('size', 'Unknown'),
866
+ item.get('status', 'Pending')
867
+ ])
868
+
869
+ click.echo(format_table(table_data, headers))
870
+
871
+ # Show download instructions
872
+ click.echo("\nUse the following command to check the status of your download:")
873
+ click.secho(f" das download status {request_id}", fg="cyan")
874
+
875
+ except Exception as e:
876
+ click.secho(f"Error: {e}", fg="red")
877
+
878
+ @download.command("delete-request")
879
+ @click.argument('request_id', required=True)
880
+ @pass_das_context
881
+ def delete_download_request(das_ctx, request_id):
882
+ """
883
+ Delete a download request by its ID
884
+
885
+ Example:
886
+
887
+ \b
888
+ # Delete a download request
889
+ das download delete-request 6b0e68e6-00cd-43a7-9c51-d56c9c091123
890
+ """
891
+ try:
892
+ # Ensure client and download_manager are initialized
893
+ das_ctx.get_client()
894
+
895
+ # Call the delete_download_request method
896
+ result = das_ctx.download_manager.delete_download_request(request_id)
897
+
898
+ # Display success message
899
+ click.secho(f"✓ Download request '{request_id}' deleted successfully!", fg="green")
900
+
901
+ except Exception as e:
902
+ click.secho(f"Error: {e}", fg="red")
903
+
904
+
905
+ @download.command("my-requests")
906
+ @click.option('--format', 'output_format', default='table', type=click.Choice(['table', 'json']), help='Output format (table or json)')
907
+ @pass_das_context
908
+ def list_my_download_requests(das_ctx, output_format):
909
+ """List your download requests in a user-friendly format"""
910
+ try:
911
+ # Ensure services are initialized
912
+ das_ctx.get_client()
913
+ result = das_ctx.download_manager.get_my_requests()
914
+
915
+ # Normalize result shape
916
+ total_count = 0
917
+ items = []
918
+ if isinstance(result, dict):
919
+ total_count = result.get('totalCount', 0)
920
+ items = result.get('items', [])
921
+ elif isinstance(result, list):
922
+ items = result
923
+ total_count = len(items)
924
+
925
+ if output_format == 'json':
926
+ payload = { 'totalCount': total_count, 'items': items }
927
+ click.echo(json.dumps(payload, indent=2))
928
+ return
929
+
930
+ if not items:
931
+ click.secho("No download requests found.", fg="yellow")
932
+ return
933
+
934
+ # Build a compact table
935
+ headers = ["#", "ID", "Requester", "Created", "Status", "Files"]
936
+ table_data = []
937
+
938
+ from das.common.enums import DownloadRequestStatus
939
+
940
+ def map_status(code):
941
+ try:
942
+ return DownloadRequestStatus(code).name.replace('_', ' ').title()
943
+ except Exception:
944
+ return str(code)
945
+
946
+ def fmt_dt(dt_str):
947
+ if not isinstance(dt_str, str) or 'T' not in dt_str:
948
+ return dt_str or ''
949
+ date_part = dt_str.split('T')[0]
950
+ time_part = dt_str.split('T')[1].split('.')[0]
951
+ return f"{date_part} {time_part}"
952
+
953
+ for i, req in enumerate(items, 1):
954
+ req_id = req.get('id', '')
955
+ requester = req.get('requester', '')
956
+ created = fmt_dt(req.get('createdOn'))
957
+ status = map_status(req.get('status'))
958
+ files = req.get('files') or []
959
+ file_count = len(files)
960
+ table_data.append([
961
+ i,
962
+ req_id[:8],
963
+ requester.split(' - ')[0] if isinstance(requester, str) else requester,
964
+ created,
965
+ status,
966
+ file_count
967
+ ])
968
+
969
+ click.secho(f"\nYour download requests ({total_count})", fg="blue")
970
+ click.echo(format_table(table_data, headers))
971
+
972
+ # Show a brief file breakdown below the table
973
+ click.secho("\nDetails:", fg="blue")
974
+ for i, req in enumerate(items, 1):
975
+ files = req.get('files') or []
976
+ if not files:
977
+ continue
978
+ click.echo(f"{i}. {req.get('id', '')}")
979
+ for f in files[:5]:
980
+ # Map item status if present
981
+ status_code = f.get('status')
982
+ status_label = None
983
+ try:
984
+ from das.common.enums import DownloadRequestItemStatus
985
+ status_label = DownloadRequestItemStatus(status_code).name.replace('_', ' ').title()
986
+ except Exception:
987
+ status_label = str(status_code)
988
+ click.echo(f" - {f.get('fileName', f.get('needle', ''))} [{status_label}] ({f.get('digitalObjectType', '')})")
989
+ if len(files) > 5:
990
+ click.echo(f" ... and {len(files) - 5} more")
991
+
992
+ except Exception as e:
993
+ click.secho(f"Error: {e}", fg="red")
994
+
995
+ # DAS AI commands group
996
+ @cli.group()
997
+ def ai():
998
+ """Commands for working with DAS AI"""
999
+ pass
1000
+
1001
+ @ai.command("enable")
1002
+ @pass_das_context
1003
+ def enable_das_ai(das_ctx):
1004
+ """Enable DAS AI interactive mode"""
1005
+ try:
1006
+ # Ensure OpenAI API key is configured
1007
+ api_key = os.getenv("OPENAI_API_KEY") or load_openai_api_key()
1008
+ if not api_key:
1009
+ click.secho("No OpenAI API key found.", fg="yellow")
1010
+ click.echo("You can set it via environment variable OPENAI_API_KEY, or save it now.")
1011
+ key = click.prompt("Enter your OpenAI API key", hide_input=True)
1012
+ if not key:
1013
+ raise click.UsageError("OpenAI API key is required to enable DAS AI.")
1014
+ save_openai_api_key(key)
1015
+ click.secho("✓ OpenAI API key saved securely.", fg="green")
1016
+
1017
+ # Get DAS AI instance
1018
+ das_ai = das_ctx.get_das_ai()
1019
+
1020
+ click.secho("🤖 DAS AI is now enabled!", fg="green", bold=True)
1021
+ click.echo("Starting interactive AI session...")
1022
+ click.echo("Type 'exit' to quit the AI session.")
1023
+ click.echo("=" * 50)
1024
+
1025
+ # Run the AI main loop
1026
+ import asyncio
1027
+ asyncio.run(das_ai.main())
1028
+
1029
+ except Exception as e:
1030
+ click.secho(f"Error enabling DAS AI: {e}", fg="red")
1031
+ click.echo("Make sure you have set your OPENAI_API_KEY environment variable.")
1032
+
1033
+ def _ai_clear_impl(force: bool):
1034
+ if not force:
1035
+ if not click.confirm("This will remove your saved OpenAI API key and authentication token. Continue?"):
1036
+ click.echo("Operation cancelled.")
1037
+ return
1038
+ try:
1039
+ clear_openai_api_key()
1040
+ clear_token()
1041
+ cfg_dir = _config_dir()
1042
+ removed_any = False
1043
+ for fname in ["openai_key.json", "token.json"]:
1044
+ p = cfg_dir / fname
1045
+ if p.exists() and p.is_file():
1046
+ try:
1047
+ p.unlink()
1048
+ removed_any = True
1049
+ except Exception:
1050
+ pass
1051
+ click.secho("✓ DAS AI credentials cleared.", fg="green")
1052
+ if removed_any:
1053
+ click.echo("Local credential files removed.")
1054
+ except Exception as e:
1055
+ click.secho(f"Error clearing credentials: {e}", fg="red")
1056
+
1057
+ @ai.command("clear")
1058
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
1059
+ def ai_clear(force):
1060
+ """Clear DAS AI credentials (OpenAI key) and auth token."""
1061
+ _ai_clear_impl(force)
1062
+
1063
+ @ai.command("logout")
1064
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
1065
+ def ai_logout(force):
1066
+ """Alias for 'das ai clear'"""
1067
+ _ai_clear_impl(force)
1068
+
1069
+ if __name__ == "__main__":
1070
+ cli()