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