pvw-cli 1.2.8__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.

Potentially problematic release.


This version of pvw-cli might be problematic. Click here for more details.

Files changed (60) hide show
  1. purviewcli/__init__.py +27 -0
  2. purviewcli/__main__.py +15 -0
  3. purviewcli/cli/__init__.py +5 -0
  4. purviewcli/cli/account.py +199 -0
  5. purviewcli/cli/cli.py +170 -0
  6. purviewcli/cli/collections.py +502 -0
  7. purviewcli/cli/domain.py +361 -0
  8. purviewcli/cli/entity.py +2436 -0
  9. purviewcli/cli/glossary.py +533 -0
  10. purviewcli/cli/health.py +250 -0
  11. purviewcli/cli/insight.py +113 -0
  12. purviewcli/cli/lineage.py +1103 -0
  13. purviewcli/cli/management.py +141 -0
  14. purviewcli/cli/policystore.py +103 -0
  15. purviewcli/cli/relationship.py +75 -0
  16. purviewcli/cli/scan.py +357 -0
  17. purviewcli/cli/search.py +527 -0
  18. purviewcli/cli/share.py +478 -0
  19. purviewcli/cli/types.py +831 -0
  20. purviewcli/cli/unified_catalog.py +3540 -0
  21. purviewcli/cli/workflow.py +402 -0
  22. purviewcli/client/__init__.py +21 -0
  23. purviewcli/client/_account.py +1877 -0
  24. purviewcli/client/_collections.py +1761 -0
  25. purviewcli/client/_domain.py +414 -0
  26. purviewcli/client/_entity.py +3545 -0
  27. purviewcli/client/_glossary.py +3233 -0
  28. purviewcli/client/_health.py +501 -0
  29. purviewcli/client/_insight.py +2873 -0
  30. purviewcli/client/_lineage.py +2138 -0
  31. purviewcli/client/_management.py +2202 -0
  32. purviewcli/client/_policystore.py +2915 -0
  33. purviewcli/client/_relationship.py +1351 -0
  34. purviewcli/client/_scan.py +2607 -0
  35. purviewcli/client/_search.py +1472 -0
  36. purviewcli/client/_share.py +272 -0
  37. purviewcli/client/_types.py +2708 -0
  38. purviewcli/client/_unified_catalog.py +5112 -0
  39. purviewcli/client/_workflow.py +2734 -0
  40. purviewcli/client/api_client.py +1295 -0
  41. purviewcli/client/business_rules.py +675 -0
  42. purviewcli/client/config.py +231 -0
  43. purviewcli/client/data_quality.py +433 -0
  44. purviewcli/client/endpoint.py +123 -0
  45. purviewcli/client/endpoints.py +554 -0
  46. purviewcli/client/exceptions.py +38 -0
  47. purviewcli/client/lineage_visualization.py +797 -0
  48. purviewcli/client/monitoring_dashboard.py +712 -0
  49. purviewcli/client/rate_limiter.py +30 -0
  50. purviewcli/client/retry_handler.py +125 -0
  51. purviewcli/client/scanning_operations.py +523 -0
  52. purviewcli/client/settings.py +1 -0
  53. purviewcli/client/sync_client.py +250 -0
  54. purviewcli/plugins/__init__.py +1 -0
  55. purviewcli/plugins/plugin_system.py +709 -0
  56. pvw_cli-1.2.8.dist-info/METADATA +1618 -0
  57. pvw_cli-1.2.8.dist-info/RECORD +60 -0
  58. pvw_cli-1.2.8.dist-info/WHEEL +5 -0
  59. pvw_cli-1.2.8.dist-info/entry_points.txt +3 -0
  60. pvw_cli-1.2.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3540 @@
1
+ """
2
+ Microsoft Purview Unified Catalog CLI Commands
3
+ Replaces data_product functionality with comprehensive Unified Catalog operations
4
+ """
5
+
6
+ import click
7
+ import csv
8
+ import json
9
+ import tempfile
10
+ import os
11
+ import time
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+ from rich.syntax import Syntax
16
+ from purviewcli.client._unified_catalog import UnifiedCatalogClient
17
+
18
+ console = Console()
19
+
20
+
21
+ def _format_json_output(data):
22
+ """Format JSON output with syntax highlighting using Rich"""
23
+ # Pretty print JSON with syntax highlighting
24
+ json_str = json.dumps(data, indent=2)
25
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=True)
26
+ console.print(syntax)
27
+
28
+
29
+ @click.group()
30
+ def uc():
31
+ """Manage Unified Catalog in Microsoft Purview (domains, terms, data products, OKRs, CDEs)."""
32
+ pass
33
+
34
+
35
+ # ========================================
36
+ # GOVERNANCE DOMAINS
37
+ # ========================================
38
+
39
+
40
+ @uc.group()
41
+ def domain():
42
+ """Manage governance domains."""
43
+ pass
44
+
45
+
46
+ @domain.command()
47
+ @click.option("--name", required=True, help="Name of the governance domain")
48
+ @click.option(
49
+ "--description", required=False, default="", help="Description of the governance domain"
50
+ )
51
+ @click.option(
52
+ "--type",
53
+ required=False,
54
+ default="FunctionalUnit",
55
+ help="Type of governance domain (default: FunctionalUnit). Note: UC API currently only accepts 'FunctionalUnit'.",
56
+ )
57
+ @click.option(
58
+ "--owner-id",
59
+ required=False,
60
+ help="Owner Entra ID (can be specified multiple times)",
61
+ multiple=True,
62
+ )
63
+ @click.option(
64
+ "--status",
65
+ required=False,
66
+ default="Draft",
67
+ type=click.Choice(["Draft", "Published", "Archived"]),
68
+ help="Status of the governance domain",
69
+ )
70
+ @click.option(
71
+ "--parent-id",
72
+ required=False,
73
+ help="Parent governance domain ID (create as subdomain under this domain)",
74
+ )
75
+ @click.option(
76
+ "--payload-file",
77
+ required=False,
78
+ type=click.Path(exists=True),
79
+ help="Optional JSON payload file to use for creating the domain (overrides flags if provided)",
80
+ )
81
+ def create(name, description, type, owner_id, status, parent_id, payload_file):
82
+ """Create a new governance domain."""
83
+ try:
84
+ client = UnifiedCatalogClient()
85
+
86
+ # Build args dictionary in Purview CLI format
87
+ # If payload-file is provided we will let the client read the file directly
88
+ # otherwise build args from individual flags.
89
+ args = {}
90
+ # Note: click will pass None for owner_id if not provided, but multiple=True returns ()
91
+ # We'll only include values if payload-file not used.
92
+ if locals().get('payload_file'):
93
+ args = {"--payloadFile": locals().get('payload_file')}
94
+ else:
95
+ args = {
96
+ "--name": [name],
97
+ "--description": [description],
98
+ "--type": [type],
99
+ "--status": [status],
100
+ }
101
+ if owner_id:
102
+ args["--owner-id"] = list(owner_id)
103
+ # include parent id if provided
104
+ parent_id = locals().get('parent_id')
105
+ if parent_id:
106
+ # use a consistent arg name for client lookup
107
+ args["--parent-domain-id"] = [parent_id]
108
+
109
+ # Call the client to create the governance domain
110
+ result = client.create_governance_domain(args)
111
+
112
+ if not result:
113
+ console.print("[red]ERROR:[/red] No response received")
114
+ return
115
+ if isinstance(result, dict) and "error" in result:
116
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
117
+ return
118
+
119
+ console.print(f"[green] SUCCESS:[/green] Created governance domain '{name}'")
120
+ console.print(json.dumps(result, indent=2))
121
+
122
+ except Exception as e:
123
+ console.print(f"[red]ERROR:[/red] {str(e)}")
124
+
125
+
126
+ @domain.command(name="list")
127
+ @click.option(
128
+ "--output",
129
+ type=click.Choice(["table", "json", "jsonc"]),
130
+ default="table",
131
+ help="Output format: table (default, formatted), json (plain, parseable), jsonc (colored JSON)"
132
+ )
133
+ def list_domains(output):
134
+ """List all governance domains.
135
+
136
+ Output formats:
137
+ - table: Formatted table output with Rich (default)
138
+ - json: Plain JSON for scripting (use with PowerShell ConvertFrom-Json)
139
+ - jsonc: Colored JSON with syntax highlighting for viewing
140
+ """
141
+ try:
142
+ client = UnifiedCatalogClient()
143
+ args = {} # No arguments needed for list operation
144
+ result = client.get_governance_domains(args)
145
+
146
+ if not result:
147
+ console.print("[yellow]No governance domains found.[/yellow]")
148
+ return
149
+
150
+ # Handle both list and dict responses
151
+ if isinstance(result, (list, tuple)):
152
+ domains = result
153
+ elif isinstance(result, dict):
154
+ domains = result.get("value", [])
155
+ else:
156
+ domains = []
157
+
158
+ if not domains:
159
+ console.print("[yellow]No governance domains found.[/yellow]")
160
+ return
161
+
162
+ # Handle output format
163
+ if output == "json":
164
+ # Plain JSON for scripting (PowerShell compatible)
165
+ print(json.dumps(domains, indent=2))
166
+ return
167
+ elif output == "jsonc":
168
+ # Colored JSON for viewing
169
+ _format_json_output(domains)
170
+ return
171
+
172
+ table = Table(title="Governance Domains")
173
+ table.add_column("ID", style="cyan")
174
+ table.add_column("Name", style="green")
175
+ table.add_column("Type", style="blue")
176
+ table.add_column("Status", style="yellow")
177
+ table.add_column("Owners", style="magenta")
178
+
179
+ for domain in domains:
180
+ owners = ", ".join(
181
+ [o.get("name", o.get("id", "Unknown")) for o in domain.get("owners", [])]
182
+ )
183
+ table.add_row(
184
+ domain.get("id", "N/A"),
185
+ domain.get("name", "N/A"),
186
+ domain.get("type", "N/A"),
187
+ domain.get("status", "N/A"),
188
+ owners or "None",
189
+ )
190
+
191
+ console.print(table)
192
+
193
+ except Exception as e:
194
+ console.print(f"[red]ERROR:[/red] {str(e)}")
195
+
196
+
197
+ @domain.command()
198
+ @click.option("--domain-id", required=True, help="ID of the governance domain")
199
+ def show(domain_id):
200
+ """Show details of a governance domain."""
201
+ try:
202
+ client = UnifiedCatalogClient()
203
+ args = {"--domain-id": [domain_id]}
204
+ result = client.get_governance_domain_by_id(args)
205
+
206
+ if not result:
207
+ console.print("[red]ERROR:[/red] No response received")
208
+ return
209
+ if isinstance(result, dict) and result.get("error"):
210
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Domain not found')}")
211
+ return
212
+
213
+ console.print(json.dumps(result, indent=2))
214
+ except Exception as e:
215
+ console.print(f"[red]ERROR:[/red] {str(e)}")
216
+
217
+
218
+ # ========================================
219
+ # DATA PRODUCTS (for backwards compatibility)
220
+ # ========================================
221
+
222
+
223
+ @uc.group()
224
+ def dataproduct():
225
+ """Manage data products."""
226
+ pass
227
+
228
+
229
+ @dataproduct.command()
230
+ @click.option("--name", required=True, help="Name of the data product")
231
+ @click.option("--description", required=False, default="", help="Description of the data product")
232
+ @click.option("--domain-id", required=True, help="Governance domain ID")
233
+ @click.option(
234
+ "--type",
235
+ required=False,
236
+ default="Operational",
237
+ type=click.Choice(["Operational", "Analytical", "Reference"]),
238
+ help="Type of data product",
239
+ )
240
+ @click.option(
241
+ "--owner-id",
242
+ required=False,
243
+ help="Owner Entra ID (can be specified multiple times)",
244
+ multiple=True,
245
+ )
246
+ @click.option("--business-use", required=False, default="", help="Business use description")
247
+ @click.option(
248
+ "--update-frequency",
249
+ required=False,
250
+ default="Weekly",
251
+ type=click.Choice(["Daily", "Weekly", "Monthly", "Quarterly", "Annually"]),
252
+ help="Update frequency",
253
+ )
254
+ @click.option("--endorsed", is_flag=True, help="Mark as endorsed")
255
+ @click.option(
256
+ "--status",
257
+ required=False,
258
+ default="Draft",
259
+ type=click.Choice(["Draft", "Published", "Archived"]),
260
+ help="Status of the data product",
261
+ )
262
+ def create(
263
+ name, description, domain_id, type, owner_id, business_use, update_frequency, endorsed, status
264
+ ):
265
+ """Create a new data product."""
266
+ try:
267
+ client = UnifiedCatalogClient()
268
+ owners = [{"id": oid} for oid in owner_id] if owner_id else []
269
+
270
+ # Build args dictionary in Purview CLI format
271
+ args = {
272
+ "--governance-domain-id": [domain_id],
273
+ "--name": [name],
274
+ "--description": [description],
275
+ "--type": [type],
276
+ "--status": [status],
277
+ "--business-use": [business_use],
278
+ "--update-frequency": [update_frequency],
279
+ }
280
+ if endorsed:
281
+ args["--endorsed"] = ["true"]
282
+ if owners:
283
+ args["--owner-id"] = [owner["id"] for owner in owners]
284
+
285
+ result = client.create_data_product(args)
286
+
287
+ if not result:
288
+ console.print("[red]ERROR:[/red] No response received")
289
+ return
290
+ if isinstance(result, dict) and "error" in result:
291
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
292
+ return
293
+
294
+ console.print(f"[green] SUCCESS:[/green] Created data product '{name}'")
295
+ console.print(json.dumps(result, indent=2))
296
+
297
+ except Exception as e:
298
+ console.print(f"[red]ERROR:[/red] {str(e)}")
299
+
300
+
301
+ @dataproduct.command(name="list")
302
+ @click.option("--domain-id", required=False, help="Governance domain ID (optional filter)")
303
+ @click.option("--status", required=False, help="Status filter (Draft, Published, Archived)")
304
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
305
+ def list_data_products(domain_id, status, output_json):
306
+ """List all data products (optionally filtered by domain or status)."""
307
+ try:
308
+ client = UnifiedCatalogClient()
309
+
310
+ # Build args dictionary in Purview CLI format
311
+ args = {}
312
+ if domain_id:
313
+ args["--domain-id"] = [domain_id]
314
+ if status:
315
+ args["--status"] = [status]
316
+
317
+ result = client.get_data_products(args)
318
+
319
+ # Handle both list and dict responses
320
+ if isinstance(result, (list, tuple)):
321
+ products = result
322
+ elif isinstance(result, dict):
323
+ products = result.get("value", [])
324
+ else:
325
+ products = []
326
+
327
+ if not products:
328
+ filter_msg = ""
329
+ if domain_id:
330
+ filter_msg += f" in domain '{domain_id}'"
331
+ if status:
332
+ filter_msg += f" with status '{status}'"
333
+ console.print(f"[yellow]No data products found{filter_msg}.[/yellow]")
334
+ return
335
+
336
+ # Output in JSON format if requested
337
+ if output_json:
338
+ _format_json_output(products)
339
+ return
340
+
341
+ table = Table(title="Data Products")
342
+ table.add_column("ID", style="cyan", no_wrap=True)
343
+ table.add_column("Name", style="green")
344
+ table.add_column("Domain ID", style="blue", no_wrap=True)
345
+ table.add_column("Status", style="yellow")
346
+ table.add_column("Description", style="white", max_width=50)
347
+
348
+ for product in products:
349
+ # Get domain ID and handle "N/A" display
350
+ domain_id = product.get("domain") or product.get("domainId", "")
351
+ domain_display = domain_id if domain_id else "N/A"
352
+
353
+ # Clean HTML tags from description
354
+ description = product.get("description", "")
355
+ import re
356
+ description = re.sub(r'<[^>]+>', '', description)
357
+ description = description.strip()
358
+
359
+ table.add_row(
360
+ product.get("id", "N/A"),
361
+ product.get("name", "N/A"),
362
+ domain_display,
363
+ product.get("status", "N/A"),
364
+ (description[:50] + "...") if len(description) > 50 else description,
365
+ )
366
+
367
+ console.print(table)
368
+
369
+ except Exception as e:
370
+ console.print(f"[red]ERROR:[/red] {str(e)}")
371
+
372
+
373
+ @dataproduct.command()
374
+ @click.option("--product-id", required=True, help="ID of the data product")
375
+ def show(product_id):
376
+ """Show details of a data product."""
377
+ try:
378
+ client = UnifiedCatalogClient()
379
+ args = {"--product-id": [product_id]}
380
+ result = client.get_data_product_by_id(args)
381
+
382
+ if not result:
383
+ console.print("[red]ERROR:[/red] No response received")
384
+ return
385
+ if isinstance(result, dict) and "error" in result:
386
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Data product not found')}")
387
+ return
388
+
389
+ console.print(json.dumps(result, indent=2))
390
+
391
+ except Exception as e:
392
+ console.print(f"[red]ERROR:[/red] {str(e)}")
393
+
394
+
395
+ @dataproduct.command()
396
+ @click.option("--product-id", required=True, help="ID of the data product to update")
397
+ @click.option("--name", required=False, help="Name of the data product")
398
+ @click.option("--description", required=False, help="Description of the data product")
399
+ @click.option("--domain-id", required=False, help="Governance domain ID")
400
+ @click.option(
401
+ "--type",
402
+ required=False,
403
+ type=click.Choice(["Operational", "Analytical", "Reference"]),
404
+ help="Type of data product",
405
+ )
406
+ @click.option(
407
+ "--owner-id",
408
+ required=False,
409
+ help="Owner Entra ID (can be specified multiple times)",
410
+ multiple=True,
411
+ )
412
+ @click.option("--business-use", required=False, help="Business use description")
413
+ @click.option(
414
+ "--update-frequency",
415
+ required=False,
416
+ type=click.Choice(["Daily", "Weekly", "Monthly", "Quarterly", "Annually"]),
417
+ help="Update frequency",
418
+ )
419
+ @click.option("--endorsed", is_flag=True, help="Mark as endorsed")
420
+ @click.option(
421
+ "--status",
422
+ required=False,
423
+ type=click.Choice(["Draft", "Published", "Archived"]),
424
+ help="Status of the data product",
425
+ )
426
+ def update(
427
+ product_id, name, description, domain_id, type, owner_id, business_use, update_frequency, endorsed, status
428
+ ):
429
+ """Update an existing data product."""
430
+ try:
431
+ client = UnifiedCatalogClient()
432
+
433
+ # Build args dictionary - only include provided values
434
+ args = {"--product-id": [product_id]}
435
+
436
+ if name:
437
+ args["--name"] = [name]
438
+ if description is not None: # Allow empty string
439
+ args["--description"] = [description]
440
+ if domain_id:
441
+ args["--domain-id"] = [domain_id]
442
+ if type:
443
+ args["--type"] = [type]
444
+ if status:
445
+ args["--status"] = [status]
446
+ if business_use is not None:
447
+ args["--business-use"] = [business_use]
448
+ if update_frequency:
449
+ args["--update-frequency"] = [update_frequency]
450
+ if endorsed:
451
+ args["--endorsed"] = ["true"]
452
+ if owner_id:
453
+ args["--owner-id"] = list(owner_id)
454
+
455
+ result = client.update_data_product(args)
456
+
457
+ if not result:
458
+ console.print("[red]ERROR:[/red] No response received")
459
+ return
460
+ if isinstance(result, dict) and "error" in result:
461
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
462
+ return
463
+
464
+ console.print(f"[green] SUCCESS:[/green] Updated data product '{product_id}'")
465
+ console.print(json.dumps(result, indent=2))
466
+
467
+ except Exception as e:
468
+ console.print(f"[red]ERROR:[/red] {str(e)}")
469
+
470
+
471
+ @dataproduct.command()
472
+ @click.option("--product-id", required=True, help="ID of the data product to delete")
473
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
474
+ def delete(product_id, yes):
475
+ """Delete a data product."""
476
+ try:
477
+ if not yes:
478
+ confirm = click.confirm(
479
+ f"Are you sure you want to delete data product '{product_id}'?",
480
+ default=False
481
+ )
482
+ if not confirm:
483
+ console.print("[yellow]Deletion cancelled.[/yellow]")
484
+ return
485
+
486
+ client = UnifiedCatalogClient()
487
+ args = {"--product-id": [product_id]}
488
+ result = client.delete_data_product(args)
489
+
490
+ # DELETE operations may return empty response on success
491
+ if result is None or (isinstance(result, dict) and not result.get("error")):
492
+ console.print(f"[green] SUCCESS:[/green] Deleted data product '{product_id}'")
493
+ elif isinstance(result, dict) and "error" in result:
494
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
495
+ else:
496
+ console.print(f"[green] SUCCESS:[/green] Deleted data product '{product_id}'")
497
+ if result:
498
+ console.print(json.dumps(result, indent=2))
499
+
500
+ except Exception as e:
501
+ console.print(f"[red]ERROR:[/red] {str(e)}")
502
+
503
+
504
+ @dataproduct.command(name="add-relationship")
505
+ @click.option("--product-id", required=True, help="Data product ID (GUID)")
506
+ @click.option("--entity-type", required=True,
507
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "CRITICALDATAELEMENT"], case_sensitive=False),
508
+ help="Type of entity to relate to")
509
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to relate to")
510
+ @click.option("--asset-id", help="Asset ID (GUID) - defaults to entity-id if not provided")
511
+ @click.option("--relationship-type", default="Related", help="Relationship type (default: Related)")
512
+ @click.option("--description", default="", help="Description of the relationship")
513
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
514
+ def add_relationship(product_id, entity_type, entity_id, asset_id, relationship_type, description, output):
515
+ """Create a relationship for a data product.
516
+
517
+ Links a data product to another entity like a critical data column, term, or asset.
518
+
519
+ Examples:
520
+ pvw uc dataproduct add-relationship --product-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
521
+ pvw uc dataproduct add-relationship --product-id <id> --entity-type TERM --entity-id <term-id> --description "Primary term"
522
+ """
523
+ try:
524
+ client = UnifiedCatalogClient()
525
+ args = {
526
+ "--product-id": [product_id],
527
+ "--entity-type": [entity_type],
528
+ "--entity-id": [entity_id],
529
+ "--relationship-type": [relationship_type],
530
+ "--description": [description]
531
+ }
532
+
533
+ if asset_id:
534
+ args["--asset-id"] = [asset_id]
535
+
536
+ result = client.create_data_product_relationship(args)
537
+
538
+ if output == "json":
539
+ console.print_json(data=result)
540
+ else:
541
+ if result and isinstance(result, dict):
542
+ console.print("[green]SUCCESS:[/green] Created relationship")
543
+ table = Table(title="Data Product Relationship", show_header=True)
544
+ table.add_column("Property", style="cyan")
545
+ table.add_column("Value", style="white")
546
+
547
+ table.add_row("Entity ID", result.get("entityId", "N/A"))
548
+ table.add_row("Relationship Type", result.get("relationshipType", "N/A"))
549
+ table.add_row("Description", result.get("description", "N/A"))
550
+
551
+ if "systemData" in result:
552
+ sys_data = result["systemData"]
553
+ table.add_row("Created By", sys_data.get("createdBy", "N/A"))
554
+ table.add_row("Created At", sys_data.get("createdAt", "N/A"))
555
+
556
+ console.print(table)
557
+ else:
558
+ console.print("[green]SUCCESS:[/green] Created relationship")
559
+
560
+ except Exception as e:
561
+ console.print(f"[red]ERROR:[/red] {str(e)}")
562
+
563
+
564
+ @dataproduct.command(name="list-relationships")
565
+ @click.option("--product-id", required=True, help="Data product ID (GUID)")
566
+ @click.option("--entity-type",
567
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "CRITICALDATAELEMENT"], case_sensitive=False),
568
+ help="Filter by entity type (optional)")
569
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
570
+ def list_relationships(product_id, entity_type, output):
571
+ """List relationships for a data product.
572
+
573
+ Shows all entities linked to this data product, optionally filtered by type.
574
+
575
+ Examples:
576
+ pvw uc dataproduct list-relationships --product-id <id>
577
+ pvw uc dataproduct list-relationships --product-id <id> --entity-type CRITICALDATACOLUMN
578
+ """
579
+ try:
580
+ client = UnifiedCatalogClient()
581
+ args = {"--product-id": [product_id]}
582
+
583
+ if entity_type:
584
+ args["--entity-type"] = [entity_type]
585
+
586
+ result = client.get_data_product_relationships(args)
587
+
588
+ if output == "json":
589
+ console.print_json(data=result)
590
+ else:
591
+ relationships = result.get("value", []) if result else []
592
+
593
+ if not relationships:
594
+ console.print(f"[yellow]No relationships found for data product '{product_id}'[/yellow]")
595
+ return
596
+
597
+ table = Table(title=f"Data Product Relationships ({len(relationships)} found)", show_header=True)
598
+ table.add_column("Entity ID", style="cyan")
599
+ table.add_column("Relationship Type", style="white")
600
+ table.add_column("Description", style="white")
601
+ table.add_column("Created", style="dim")
602
+
603
+ for rel in relationships:
604
+ table.add_row(
605
+ rel.get("entityId", "N/A"),
606
+ rel.get("relationshipType", "N/A"),
607
+ rel.get("description", "")[:50] + ("..." if len(rel.get("description", "")) > 50 else ""),
608
+ rel.get("systemData", {}).get("createdAt", "N/A")[:10]
609
+ )
610
+
611
+ console.print(table)
612
+
613
+ except Exception as e:
614
+ console.print(f"[red]ERROR:[/red] {str(e)}")
615
+
616
+
617
+ @dataproduct.command(name="remove-relationship")
618
+ @click.option("--product-id", required=True, help="Data product ID (GUID)")
619
+ @click.option("--entity-type", required=True,
620
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "CRITICALDATAELEMENT"], case_sensitive=False),
621
+ help="Type of entity to unlink")
622
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to unlink")
623
+ @click.option("--confirm/--no-confirm", default=True, help="Ask for confirmation before deleting")
624
+ def remove_relationship(product_id, entity_type, entity_id, confirm):
625
+ """Delete a relationship between a data product and an entity.
626
+
627
+ Removes the link between a data product and a specific entity.
628
+
629
+ Examples:
630
+ pvw uc dataproduct remove-relationship --product-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
631
+ pvw uc dataproduct remove-relationship --product-id <id> --entity-type TERM --entity-id <term-id> --no-confirm
632
+ """
633
+ try:
634
+ if confirm:
635
+ confirm = click.confirm(
636
+ f"Are you sure you want to delete relationship to {entity_type} '{entity_id}'?",
637
+ default=False
638
+ )
639
+ if not confirm:
640
+ console.print("[yellow]Deletion cancelled.[/yellow]")
641
+ return
642
+
643
+ client = UnifiedCatalogClient()
644
+ args = {
645
+ "--product-id": [product_id],
646
+ "--entity-type": [entity_type],
647
+ "--entity-id": [entity_id]
648
+ }
649
+
650
+ result = client.delete_data_product_relationship(args)
651
+
652
+ # DELETE returns 204 No Content on success
653
+ if result is None or (isinstance(result, dict) and not result.get("error")):
654
+ console.print(f"[green]SUCCESS:[/green] Deleted relationship to {entity_type} '{entity_id}'")
655
+ elif isinstance(result, dict) and "error" in result:
656
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
657
+ else:
658
+ console.print(f"[green]SUCCESS:[/green] Deleted relationship")
659
+
660
+ except Exception as e:
661
+ console.print(f"[red]ERROR:[/red] {str(e)}")
662
+
663
+
664
+ @dataproduct.command(name="query")
665
+ @click.option("--ids", multiple=True, help="Filter by specific product IDs (GUIDs)")
666
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
667
+ @click.option("--name-keyword", help="Filter by name keyword (partial match)")
668
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
669
+ @click.option("--status", type=click.Choice(["Draft", "Published", "Expired"], case_sensitive=False),
670
+ help="Filter by status")
671
+ @click.option("--multi-status", multiple=True,
672
+ type=click.Choice(["Draft", "Published", "Expired"], case_sensitive=False),
673
+ help="Filter by multiple statuses")
674
+ @click.option("--type", help="Filter by data product type (e.g., Master, Operational)")
675
+ @click.option("--types", multiple=True, help="Filter by multiple data product types")
676
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
677
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
678
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
679
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
680
+ help="Sort direction")
681
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
682
+ def query_data_products(ids, domain_ids, name_keyword, owners, status, multi_status, type, types,
683
+ skip, top, order_by_field, order_by_direction, output):
684
+ """Query data products with advanced filters.
685
+
686
+ Perform complex searches across data products using multiple filter criteria.
687
+ Supports pagination and custom sorting.
688
+
689
+ Examples:
690
+ # Find all data products in a specific domain
691
+ pvw uc dataproduct query --domain-ids <domain-guid>
692
+
693
+ # Search by keyword
694
+ pvw uc dataproduct query --name-keyword "customer"
695
+
696
+ # Filter by owner and status
697
+ pvw uc dataproduct query --owners <user-guid> --status Published
698
+
699
+ # Pagination example
700
+ pvw uc dataproduct query --skip 0 --top 50 --order-by-field name
701
+
702
+ # Multiple filters
703
+ pvw uc dataproduct query --domain-ids <guid1> <guid2> --status Published --type Master
704
+ """
705
+ try:
706
+ client = UnifiedCatalogClient()
707
+ args = {}
708
+
709
+ # Build args dict from parameters
710
+ if ids:
711
+ args["--ids"] = list(ids)
712
+ if domain_ids:
713
+ args["--domain-ids"] = list(domain_ids)
714
+ if name_keyword:
715
+ args["--name-keyword"] = [name_keyword]
716
+ if owners:
717
+ args["--owners"] = list(owners)
718
+ if status:
719
+ args["--status"] = [status]
720
+ if multi_status:
721
+ args["--multi-status"] = list(multi_status)
722
+ if type:
723
+ args["--type"] = [type]
724
+ if types:
725
+ args["--types"] = list(types)
726
+ if skip:
727
+ args["--skip"] = [str(skip)]
728
+ if top:
729
+ args["--top"] = [str(top)]
730
+ if order_by_field:
731
+ args["--order-by-field"] = [order_by_field]
732
+ args["--order-by-direction"] = [order_by_direction]
733
+
734
+ result = client.query_data_products(args)
735
+
736
+ if output == "json":
737
+ console.print_json(data=result)
738
+ else:
739
+ products = result.get("value", []) if result else []
740
+
741
+ if not products:
742
+ console.print("[yellow]No data products found matching the query.[/yellow]")
743
+ return
744
+
745
+ # Check for pagination
746
+ next_link = result.get("nextLink")
747
+ if next_link:
748
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
749
+
750
+ table = Table(title=f"Query Results ({len(products)} found)", show_header=True)
751
+ table.add_column("Name", style="cyan")
752
+ table.add_column("ID", style="dim", no_wrap=True)
753
+ table.add_column("Domain", style="yellow", no_wrap=True)
754
+ table.add_column("Type", style="green")
755
+ table.add_column("Status", style="white")
756
+ table.add_column("Owner", style="magenta")
757
+
758
+ for product in products:
759
+ # Extract owner info
760
+ contacts = product.get("contacts", {})
761
+ owners_list = contacts.get("owner", [])
762
+ owner_display = owners_list[0].get("id", "N/A")[:8] if owners_list else "N/A"
763
+
764
+ table.add_row(
765
+ product.get("name", "N/A"),
766
+ product.get("id", "N/A")[:13] + "...",
767
+ product.get("domain", "N/A")[:13] + "...",
768
+ product.get("type", "N/A"),
769
+ product.get("status", "N/A"),
770
+ owner_display + "..."
771
+ )
772
+
773
+ console.print(table)
774
+
775
+ # Show pagination info
776
+ if skip > 0 or next_link:
777
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(products)}[/dim]")
778
+
779
+ except Exception as e:
780
+ console.print(f"[red]ERROR:[/red] {str(e)}")
781
+
782
+
783
+ # ========================================
784
+ # GLOSSARIES
785
+ # ========================================
786
+ @uc.group()
787
+ def glossary():
788
+ """Manage glossaries (for finding glossary GUIDs)."""
789
+ pass
790
+
791
+
792
+ @glossary.command(name="list")
793
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
794
+ def list_glossaries(output_json):
795
+ """List all glossaries with their GUIDs."""
796
+ try:
797
+ from purviewcli.client._glossary import Glossary
798
+
799
+ client = Glossary()
800
+ result = client.glossaryRead({})
801
+
802
+ # Normalize response
803
+ if isinstance(result, dict):
804
+ glossaries = result.get("value", []) or []
805
+ elif isinstance(result, (list, tuple)):
806
+ glossaries = result
807
+ else:
808
+ glossaries = []
809
+
810
+ if not glossaries:
811
+ console.print("[yellow]No glossaries found.[/yellow]")
812
+ return
813
+
814
+ # Output in JSON format if requested
815
+ if output_json:
816
+ _format_json_output(glossaries)
817
+ return
818
+
819
+ table = Table(title="Glossaries")
820
+ table.add_column("GUID", style="cyan", no_wrap=True)
821
+ table.add_column("Name", style="green")
822
+ table.add_column("Qualified Name", style="yellow")
823
+ table.add_column("Description", style="white")
824
+
825
+ for g in glossaries:
826
+ if not isinstance(g, dict):
827
+ continue
828
+ table.add_row(
829
+ g.get("guid", "N/A"),
830
+ g.get("name", "N/A"),
831
+ g.get("qualifiedName", "N/A"),
832
+ (g.get("shortDescription", "")[:60] + "...") if len(g.get("shortDescription", "")) > 60 else g.get("shortDescription", ""),
833
+ )
834
+
835
+ console.print(table)
836
+ console.print("\n[dim]Tip: Use the GUID with --glossary-guid option when listing/creating terms[/dim]")
837
+
838
+ except Exception as e:
839
+ console.print(f"[red]ERROR:[/red] {str(e)}")
840
+
841
+
842
+ @glossary.command(name="create")
843
+ @click.option("--name", required=True, help="Name of the glossary")
844
+ @click.option("--description", required=False, default="", help="Description of the glossary")
845
+ @click.option("--domain-id", required=False, help="Associate with governance domain ID (optional)")
846
+ def create_glossary(name, description, domain_id):
847
+ """Create a new glossary."""
848
+ try:
849
+ from purviewcli.client._glossary import Glossary
850
+
851
+ client = Glossary()
852
+
853
+ # Build qualified name - include domain_id if provided
854
+ if domain_id:
855
+ qualified_name = f"{name}@{domain_id}"
856
+ else:
857
+ qualified_name = name
858
+
859
+ payload = {
860
+ "name": name,
861
+ "qualifiedName": qualified_name,
862
+ "shortDescription": description,
863
+ "longDescription": description,
864
+ }
865
+
866
+ result = client.glossaryCreate({"--payloadFile": payload})
867
+
868
+ if not result:
869
+ console.print("[red]ERROR:[/red] No response received")
870
+ return
871
+ if isinstance(result, dict) and "error" in result:
872
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
873
+ return
874
+
875
+ guid = result.get("guid") if isinstance(result, dict) else None
876
+ console.print(f"[green] SUCCESS:[/green] Created glossary '{name}'")
877
+ if guid:
878
+ console.print(f"[cyan]GUID:[/cyan] {guid}")
879
+ console.print(f"\n[dim]Use this GUID: --glossary-guid {guid}[/dim]")
880
+ console.print(json.dumps(result, indent=2))
881
+
882
+ except Exception as e:
883
+ console.print(f"[red]ERROR:[/red] {str(e)}")
884
+
885
+
886
+ @glossary.command(name="create-for-domains")
887
+ def create_glossaries_for_domains():
888
+ """Create glossaries for all governance domains that don't have one."""
889
+ try:
890
+ from purviewcli.client._glossary import Glossary
891
+
892
+ uc_client = UnifiedCatalogClient()
893
+ glossary_client = Glossary()
894
+
895
+ # Get all domains
896
+ domains_result = uc_client.get_governance_domains({})
897
+ if isinstance(domains_result, dict):
898
+ domains = domains_result.get("value", [])
899
+ elif isinstance(domains_result, (list, tuple)):
900
+ domains = domains_result
901
+ else:
902
+ domains = []
903
+
904
+ if not domains:
905
+ console.print("[yellow]No governance domains found.[/yellow]")
906
+ return
907
+
908
+ # Get existing glossaries
909
+ glossaries_result = glossary_client.glossaryRead({})
910
+ if isinstance(glossaries_result, dict):
911
+ existing_glossaries = glossaries_result.get("value", [])
912
+ elif isinstance(glossaries_result, (list, tuple)):
913
+ existing_glossaries = glossaries_result
914
+ else:
915
+ existing_glossaries = []
916
+
917
+ # Build set of domain IDs that already have glossaries (check qualifiedName)
918
+ existing_domain_ids = set()
919
+ for g in existing_glossaries:
920
+ if isinstance(g, dict):
921
+ qn = g.get("qualifiedName", "")
922
+ # Extract domain_id from qualifiedName if it contains @domain_id pattern
923
+ if "@" in qn:
924
+ domain_id_part = qn.split("@")[-1]
925
+ existing_domain_ids.add(domain_id_part)
926
+
927
+ console.print(f"[cyan]Found {len(domains)} governance domains and {len(existing_glossaries)} existing glossaries[/cyan]\n")
928
+
929
+ created_count = 0
930
+ for domain in domains:
931
+ if not isinstance(domain, dict):
932
+ continue
933
+
934
+ domain_id = domain.get("id")
935
+ domain_name = domain.get("name")
936
+
937
+ if not domain_id or not domain_name:
938
+ continue
939
+
940
+ # Check if glossary already exists for this domain
941
+ if domain_id in existing_domain_ids:
942
+ console.print(f"[dim]⏭ Skipping {domain_name} - glossary already exists[/dim]")
943
+ continue
944
+
945
+ # Create glossary for this domain
946
+ glossary_name = f"{domain_name} Glossary"
947
+ qualified_name = f"{glossary_name}@{domain_id}"
948
+
949
+ payload = {
950
+ "name": glossary_name,
951
+ "qualifiedName": qualified_name,
952
+ "shortDescription": f"Glossary for {domain_name} domain",
953
+ "longDescription": f"This glossary contains business terms for the {domain_name} governance domain.",
954
+ }
955
+
956
+ try:
957
+ result = glossary_client.glossaryCreate({"--payloadFile": payload})
958
+ guid = result.get("guid") if isinstance(result, dict) else None
959
+
960
+ if guid:
961
+ console.print(f"[green] Created:[/green] {glossary_name} (GUID: {guid})")
962
+ created_count += 1
963
+ else:
964
+ console.print(f"[yellow] Created {glossary_name} but no GUID returned[/yellow]")
965
+
966
+ except Exception as e:
967
+ console.print(f"[red] Failed to create {glossary_name}:[/red] {str(e)}")
968
+
969
+ console.print(f"\n[cyan]Created {created_count} new glossaries[/cyan]")
970
+ console.print("[dim]Run 'pvw uc glossary list' to see all glossaries[/dim]")
971
+
972
+ except Exception as e:
973
+ console.print(f"[red]ERROR:[/red] {str(e)}")
974
+
975
+
976
+ @glossary.command(name="verify-links")
977
+ def verify_glossary_links():
978
+ """Verify which domains have properly linked glossaries."""
979
+ try:
980
+ from purviewcli.client._glossary import Glossary
981
+
982
+ uc_client = UnifiedCatalogClient()
983
+ glossary_client = Glossary()
984
+
985
+ # Get all domains
986
+ domains_result = uc_client.get_governance_domains({})
987
+ if isinstance(domains_result, dict):
988
+ domains = domains_result.get("value", [])
989
+ elif isinstance(domains_result, (list, tuple)):
990
+ domains = domains_result
991
+ else:
992
+ domains = []
993
+
994
+ # Get all glossaries
995
+ glossaries_result = glossary_client.glossaryRead({})
996
+ if isinstance(glossaries_result, dict):
997
+ glossaries = glossaries_result.get("value", [])
998
+ elif isinstance(glossaries_result, (list, tuple)):
999
+ glossaries = glossaries_result
1000
+ else:
1001
+ glossaries = []
1002
+
1003
+ console.print(f"[bold cyan]Governance Domain → Glossary Link Verification[/bold cyan]\n")
1004
+
1005
+ table = Table(title="Domain-Glossary Associations")
1006
+ table.add_column("Domain Name", style="green")
1007
+ table.add_column("Domain ID", style="cyan", no_wrap=True)
1008
+ table.add_column("Linked Glossary", style="yellow")
1009
+ table.add_column("Glossary GUID", style="magenta", no_wrap=True)
1010
+ table.add_column("Status", style="white")
1011
+
1012
+ # Build a map of domain_id -> glossary info
1013
+ domain_glossary_map = {}
1014
+ for g in glossaries:
1015
+ if not isinstance(g, dict):
1016
+ continue
1017
+ qn = g.get("qualifiedName", "")
1018
+ # Check if qualifiedName contains @domain_id pattern
1019
+ if "@" in qn:
1020
+ domain_id_part = qn.split("@")[-1]
1021
+ domain_glossary_map[domain_id_part] = {
1022
+ "name": g.get("name"),
1023
+ "guid": g.get("guid"),
1024
+ "qualifiedName": qn,
1025
+ }
1026
+
1027
+ linked_count = 0
1028
+ unlinked_count = 0
1029
+
1030
+ for domain in domains:
1031
+ if not isinstance(domain, dict):
1032
+ continue
1033
+
1034
+ domain_id = domain.get("id")
1035
+ domain_name = domain.get("name", "N/A")
1036
+ parent_id = domain.get("parentDomainId")
1037
+
1038
+ # Skip if no domain_id
1039
+ if not domain_id:
1040
+ continue
1041
+
1042
+ # Show if it's a nested domain
1043
+ nested_indicator = " (nested)" if parent_id else ""
1044
+ domain_display = f"{domain_name}{nested_indicator}"
1045
+
1046
+ if domain_id in domain_glossary_map:
1047
+ glossary_info = domain_glossary_map[domain_id]
1048
+ table.add_row(
1049
+ domain_display,
1050
+ domain_id[:8] + "...",
1051
+ glossary_info["name"],
1052
+ glossary_info["guid"][:8] + "...",
1053
+ "[green] Linked[/green]"
1054
+ )
1055
+ linked_count += 1
1056
+ else:
1057
+ table.add_row(
1058
+ domain_display,
1059
+ domain_id[:8] + "...",
1060
+ "[dim]No glossary[/dim]",
1061
+ "[dim]N/A[/dim]",
1062
+ "[yellow] Not Linked[/yellow]"
1063
+ )
1064
+ unlinked_count += 1
1065
+
1066
+ console.print(table)
1067
+ console.print(f"\n[cyan]Summary:[/cyan]")
1068
+ console.print(f" • Linked domains: [green]{linked_count}[/green]")
1069
+ console.print(f" • Unlinked domains: [yellow]{unlinked_count}[/yellow]")
1070
+
1071
+ if unlinked_count > 0:
1072
+ console.print(f"\n[dim]💡 Tip: Run 'pvw uc glossary create-for-domains' to create glossaries for unlinked domains[/dim]")
1073
+
1074
+ except Exception as e:
1075
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1076
+
1077
+
1078
+ # ========================================
1079
+ # GLOSSARY TERMS
1080
+ # ========================================
1081
+
1082
+
1083
+ @uc.group()
1084
+ def term():
1085
+ """Manage glossary terms."""
1086
+ pass
1087
+
1088
+
1089
+ @term.command()
1090
+ @click.option("--name", required=True, help="Name of the glossary term")
1091
+ @click.option("--description", required=False, default="", help="Rich text description of the term")
1092
+ @click.option("--domain-id", required=True, help="Governance domain ID")
1093
+ @click.option("--parent-id", required=False, help="Parent term ID (for hierarchical terms)")
1094
+ @click.option(
1095
+ "--status",
1096
+ required=False,
1097
+ default="Draft",
1098
+ type=click.Choice(["Draft", "Published", "Archived"]),
1099
+ help="Status of the term",
1100
+ )
1101
+ @click.option(
1102
+ "--acronym",
1103
+ required=False,
1104
+ help="Acronyms for the term (can be specified multiple times)",
1105
+ multiple=True,
1106
+ )
1107
+ @click.option(
1108
+ "--owner-id",
1109
+ required=False,
1110
+ help="Owner Entra ID (can be specified multiple times)",
1111
+ multiple=True,
1112
+ )
1113
+ @click.option("--resource-name", required=False, help="Resource name for additional reading (can be specified multiple times)", multiple=True)
1114
+ @click.option("--resource-url", required=False, help="Resource URL for additional reading (can be specified multiple times)", multiple=True)
1115
+ def create(name, description, domain_id, parent_id, status, acronym, owner_id, resource_name, resource_url):
1116
+ """Create a new Unified Catalog term (Governance Domain term)."""
1117
+ try:
1118
+ client = UnifiedCatalogClient()
1119
+
1120
+ # Build args dictionary
1121
+ args = {
1122
+ "--name": [name],
1123
+ "--description": [description],
1124
+ "--governance-domain-id": [domain_id],
1125
+ "--status": [status],
1126
+ }
1127
+
1128
+ if parent_id:
1129
+ args["--parent-id"] = [parent_id]
1130
+ if acronym:
1131
+ args["--acronym"] = list(acronym)
1132
+ if owner_id:
1133
+ args["--owner-id"] = list(owner_id)
1134
+ if resource_name:
1135
+ args["--resource-name"] = list(resource_name)
1136
+ if resource_url:
1137
+ args["--resource-url"] = list(resource_url)
1138
+
1139
+ result = client.create_term(args)
1140
+
1141
+ if not result:
1142
+ console.print("[red]ERROR:[/red] No response received")
1143
+ return
1144
+ if isinstance(result, dict) and "error" in result:
1145
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
1146
+ return
1147
+
1148
+ console.print(f"[green] SUCCESS:[/green] Created glossary term '{name}'")
1149
+ console.print(json.dumps(result, indent=2))
1150
+
1151
+ except Exception as e:
1152
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1153
+
1154
+
1155
+ @term.command(name="list")
1156
+ @click.option("--domain-id", required=True, help="Governance domain ID to list terms from")
1157
+ @click.option(
1158
+ "--output",
1159
+ type=click.Choice(["table", "json", "jsonc"]),
1160
+ default="table",
1161
+ help="Output format: table (default, formatted), json (plain, parseable), jsonc (colored JSON)"
1162
+ )
1163
+ def list_terms(domain_id, output):
1164
+ """List all Unified Catalog terms in a governance domain.
1165
+
1166
+ Output formats:
1167
+ - table: Formatted table output with Rich (default)
1168
+ - json: Plain JSON for scripting (use with PowerShell ConvertFrom-Json)
1169
+ - jsonc: Colored JSON with syntax highlighting for viewing
1170
+ """
1171
+ try:
1172
+ client = UnifiedCatalogClient()
1173
+ args = {"--governance-domain-id": [domain_id]}
1174
+ result = client.get_terms(args)
1175
+
1176
+ if not result:
1177
+ console.print("[yellow]No terms found.[/yellow]")
1178
+ return
1179
+
1180
+ # Unified Catalog API returns terms directly in value array
1181
+ all_terms = []
1182
+
1183
+ if isinstance(result, dict):
1184
+ all_terms = result.get("value", [])
1185
+ elif isinstance(result, (list, tuple)):
1186
+ all_terms = result
1187
+ else:
1188
+ console.print("[yellow]Unexpected response format.[/yellow]")
1189
+ return
1190
+
1191
+ if not all_terms:
1192
+ console.print("[yellow]No terms found.[/yellow]")
1193
+ return
1194
+
1195
+ # Handle output format
1196
+ if output == "json":
1197
+ # Plain JSON for scripting (PowerShell compatible)
1198
+ print(json.dumps(all_terms, indent=2))
1199
+ return
1200
+ elif output == "jsonc":
1201
+ # Colored JSON for viewing
1202
+ _format_json_output(all_terms)
1203
+ return
1204
+
1205
+ table = Table(title="Unified Catalog Terms")
1206
+ table.add_column("Term ID", style="cyan", no_wrap=False)
1207
+ table.add_column("Name", style="green")
1208
+ table.add_column("Status", style="yellow")
1209
+ table.add_column("Description", style="white")
1210
+
1211
+ for term in all_terms:
1212
+ description = term.get("description", "")
1213
+ # Strip HTML tags from description
1214
+ import re
1215
+ description = re.sub(r'<[^>]+>', '', description)
1216
+ # Truncate long descriptions
1217
+ if len(description) > 50:
1218
+ description = description[:50] + "..."
1219
+
1220
+ table.add_row(
1221
+ term.get("id", "N/A"),
1222
+ term.get("name", "N/A"),
1223
+ term.get("status", "N/A"),
1224
+ description.strip(),
1225
+ )
1226
+
1227
+ console.print(table)
1228
+ console.print(f"\n[dim]Found {len(all_terms)} term(s)[/dim]")
1229
+
1230
+ except Exception as e:
1231
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1232
+
1233
+
1234
+ @term.command()
1235
+ @click.option("--term-id", required=True, help="ID of the glossary term")
1236
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
1237
+ def show(term_id, output_json):
1238
+ """Show details of a glossary term."""
1239
+ try:
1240
+ client = UnifiedCatalogClient()
1241
+ args = {"--term-id": [term_id]}
1242
+ result = client.get_term_by_id(args)
1243
+
1244
+ if not result:
1245
+ console.print("[red]ERROR:[/red] No response received")
1246
+ return
1247
+ if isinstance(result, dict) and "error" in result:
1248
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Term not found')}")
1249
+ return
1250
+
1251
+ if output_json:
1252
+ _format_json_output(result)
1253
+ else:
1254
+ # Display key information in a readable format
1255
+ if isinstance(result, dict):
1256
+ console.print(f"[cyan]Term Name:[/cyan] {result.get('name', 'N/A')}")
1257
+ console.print(f"[cyan]GUID:[/cyan] {result.get('guid', 'N/A')}")
1258
+ console.print(f"[cyan]Status:[/cyan] {result.get('status', 'N/A')}")
1259
+ console.print(f"[cyan]Qualified Name:[/cyan] {result.get('qualifiedName', 'N/A')}")
1260
+
1261
+ # Show glossary info
1262
+ anchor = result.get('anchor', {})
1263
+ if anchor:
1264
+ console.print(f"[cyan]Glossary GUID:[/cyan] {anchor.get('glossaryGuid', 'N/A')}")
1265
+
1266
+ # Show description
1267
+ desc = result.get('shortDescription') or result.get('longDescription', '')
1268
+ if desc:
1269
+ console.print(f"[cyan]Description:[/cyan] {desc}")
1270
+
1271
+ # Show full JSON if needed
1272
+ console.print(f"\n[dim]Full details (JSON):[/dim]")
1273
+ console.print(json.dumps(result, indent=2))
1274
+ else:
1275
+ console.print(json.dumps(result, indent=2))
1276
+
1277
+ except Exception as e:
1278
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1279
+
1280
+
1281
+ @term.command()
1282
+ @click.option("--term-id", required=True, help="ID of the glossary term to delete")
1283
+ @click.option("--force", is_flag=True, help="Skip confirmation prompt")
1284
+ def delete(term_id, force):
1285
+ """Delete a glossary term."""
1286
+ try:
1287
+ if not force:
1288
+ # Show term details first
1289
+ client = UnifiedCatalogClient()
1290
+ term_info = client.get_term_by_id({"--term-id": [term_id]})
1291
+
1292
+ if isinstance(term_info, dict) and term_info.get('name'):
1293
+ console.print(f"[yellow]About to delete term:[/yellow]")
1294
+ console.print(f" Name: {term_info.get('name')}")
1295
+ console.print(f" GUID: {term_info.get('guid')}")
1296
+ console.print(f" Status: {term_info.get('status')}")
1297
+
1298
+ confirm = click.confirm("Are you sure you want to delete this term?", default=False)
1299
+ if not confirm:
1300
+ console.print("[yellow]Deletion cancelled.[/yellow]")
1301
+ return
1302
+
1303
+ # Import glossary client to delete term
1304
+ from purviewcli.client._glossary import Glossary
1305
+
1306
+ gclient = Glossary()
1307
+ result = gclient.glossaryDeleteTerm({"--termGuid": term_id})
1308
+
1309
+ console.print(f"[green] SUCCESS:[/green] Deleted term with ID: {term_id}")
1310
+
1311
+ except Exception as e:
1312
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1313
+
1314
+
1315
+ @term.command()
1316
+ @click.option("--term-id", required=True, help="ID of the glossary term to update")
1317
+ @click.option("--name", required=False, help="Name of the glossary term")
1318
+ @click.option("--description", required=False, help="Rich text description of the term")
1319
+ @click.option("--domain-id", required=False, help="Governance domain ID")
1320
+ @click.option("--parent-id", required=False, help="Parent term ID (for hierarchical terms)")
1321
+ @click.option(
1322
+ "--status",
1323
+ required=False,
1324
+ type=click.Choice(["Draft", "Published", "Archived"]),
1325
+ help="Status of the term",
1326
+ )
1327
+ @click.option(
1328
+ "--acronym",
1329
+ required=False,
1330
+ help="Acronyms for the term (can be specified multiple times, replaces existing)",
1331
+ multiple=True,
1332
+ )
1333
+ @click.option(
1334
+ "--owner-id",
1335
+ required=False,
1336
+ help="Owner Entra ID (can be specified multiple times, replaces existing)",
1337
+ multiple=True,
1338
+ )
1339
+ @click.option("--resource-name", required=False, help="Resource name for additional reading (can be specified multiple times, replaces existing)", multiple=True)
1340
+ @click.option("--resource-url", required=False, help="Resource URL for additional reading (can be specified multiple times, replaces existing)", multiple=True)
1341
+ @click.option("--add-acronym", required=False, help="Add acronym to existing ones (can be specified multiple times)", multiple=True)
1342
+ @click.option("--add-owner-id", required=False, help="Add owner to existing ones (can be specified multiple times)", multiple=True)
1343
+ def update(term_id, name, description, domain_id, parent_id, status, acronym, owner_id, resource_name, resource_url, add_acronym, add_owner_id):
1344
+ """Update an existing Unified Catalog term."""
1345
+ try:
1346
+ client = UnifiedCatalogClient()
1347
+
1348
+ # Build args dictionary - only include provided values
1349
+ args = {"--term-id": [term_id]}
1350
+
1351
+ if name:
1352
+ args["--name"] = [name]
1353
+ if description is not None: # Allow empty string
1354
+ args["--description"] = [description]
1355
+ if domain_id:
1356
+ args["--governance-domain-id"] = [domain_id]
1357
+ if parent_id:
1358
+ args["--parent-id"] = [parent_id]
1359
+ if status:
1360
+ args["--status"] = [status]
1361
+
1362
+ # Handle acronyms - either replace or add
1363
+ if acronym:
1364
+ args["--acronym"] = list(acronym)
1365
+ elif add_acronym:
1366
+ args["--add-acronym"] = list(add_acronym)
1367
+
1368
+ # Handle owners - either replace or add
1369
+ if owner_id:
1370
+ args["--owner-id"] = list(owner_id)
1371
+ elif add_owner_id:
1372
+ args["--add-owner-id"] = list(add_owner_id)
1373
+
1374
+ # Handle resources
1375
+ if resource_name:
1376
+ args["--resource-name"] = list(resource_name)
1377
+ if resource_url:
1378
+ args["--resource-url"] = list(resource_url)
1379
+
1380
+ result = client.update_term(args)
1381
+
1382
+ if not result:
1383
+ console.print("[red]ERROR:[/red] No response received")
1384
+ return
1385
+ if isinstance(result, dict) and "error" in result:
1386
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
1387
+ return
1388
+
1389
+ console.print(f"[green] SUCCESS:[/green] Updated glossary term '{term_id}'")
1390
+ console.print(json.dumps(result, indent=2))
1391
+
1392
+ except Exception as e:
1393
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1394
+
1395
+
1396
+ @term.command(name="import-csv")
1397
+ @click.option("--csv-file", required=True, type=click.Path(exists=True), help="Path to CSV file with terms")
1398
+ @click.option("--domain-id", required=True, help="Governance domain ID for all terms")
1399
+ @click.option("--dry-run", is_flag=True, help="Preview terms without creating them")
1400
+ def import_terms_from_csv(csv_file, domain_id, dry_run):
1401
+ """Bulk import glossary terms from a CSV file.
1402
+
1403
+ CSV Format:
1404
+ name,description,status,acronyms,owner_ids,resource_name,resource_url
1405
+
1406
+ - name: Required term name
1407
+ - description: Optional description
1408
+ - status: Draft, Published, or Archived (default: Draft)
1409
+ - acronyms: Comma-separated list (e.g., "API,REST")
1410
+ - owner_ids: Comma-separated list of Entra Object IDs
1411
+ - resource_name: Name of related resource
1412
+ - resource_url: URL of related resource
1413
+
1414
+ Multiple resources can be specified by separating with semicolons.
1415
+ """
1416
+ try:
1417
+ client = UnifiedCatalogClient()
1418
+
1419
+ # Read and parse CSV
1420
+ terms = []
1421
+ with open(csv_file, 'r', encoding='utf-8') as f:
1422
+ reader = csv.DictReader(f)
1423
+ for row in reader:
1424
+ term = {
1425
+ "name": row.get("name", "").strip(),
1426
+ "description": row.get("description", "").strip(),
1427
+ "status": row.get("status", "Draft").strip(),
1428
+ "domain_id": domain_id,
1429
+ "acronyms": [],
1430
+ "owner_ids": [],
1431
+ "resources": []
1432
+ }
1433
+
1434
+ # Parse acronyms
1435
+ if row.get("acronyms"):
1436
+ term["acronyms"] = [a.strip() for a in row["acronyms"].split(",") if a.strip()]
1437
+
1438
+ # Parse owner IDs
1439
+ if row.get("owner_ids"):
1440
+ term["owner_ids"] = [o.strip() for o in row["owner_ids"].split(",") if o.strip()]
1441
+
1442
+ # Parse resources
1443
+ resource_names = row.get("resource_name", "").strip()
1444
+ resource_urls = row.get("resource_url", "").strip()
1445
+
1446
+ if resource_names and resource_urls:
1447
+ names = [n.strip() for n in resource_names.split(";") if n.strip()]
1448
+ urls = [u.strip() for u in resource_urls.split(";") if u.strip()]
1449
+ term["resources"] = [{"name": n, "url": u} for n, u in zip(names, urls)]
1450
+
1451
+ if term["name"]: # Only add if name is present
1452
+ terms.append(term)
1453
+
1454
+ if not terms:
1455
+ console.print("[yellow]No valid terms found in CSV file.[/yellow]")
1456
+ return
1457
+
1458
+ console.print(f"[cyan]Found {len(terms)} term(s) in CSV file[/cyan]")
1459
+
1460
+ if dry_run:
1461
+ console.print("\n[yellow]DRY RUN - Preview of terms to be created:[/yellow]\n")
1462
+ table = Table(title="Terms to Import")
1463
+ table.add_column("#", style="dim", width=4)
1464
+ table.add_column("Name", style="cyan")
1465
+ table.add_column("Status", style="yellow")
1466
+ table.add_column("Acronyms", style="magenta")
1467
+ table.add_column("Owners", style="green")
1468
+
1469
+ for i, term in enumerate(terms, 1):
1470
+ acronyms = ", ".join(term.get("acronyms", []))
1471
+ owners = ", ".join(term.get("owner_ids", []))
1472
+ table.add_row(
1473
+ str(i),
1474
+ term["name"],
1475
+ term["status"],
1476
+ acronyms or "-",
1477
+ owners or "-"
1478
+ )
1479
+
1480
+ console.print(table)
1481
+ console.print(f"\n[dim]Domain ID: {domain_id}[/dim]")
1482
+ return
1483
+
1484
+ # Import terms (one by one using single POST)
1485
+ success_count = 0
1486
+ failed_count = 0
1487
+ failed_terms = []
1488
+
1489
+ with console.status("[bold green]Importing terms...") as status:
1490
+ for i, term in enumerate(terms, 1):
1491
+ status.update(f"[bold green]Creating term {i}/{len(terms)}: {term['name']}")
1492
+
1493
+ try:
1494
+ # Create individual term
1495
+ args = {
1496
+ "--name": [term["name"]],
1497
+ "--description": [term.get("description", "")],
1498
+ "--governance-domain-id": [term["domain_id"]],
1499
+ "--status": [term.get("status", "Draft")],
1500
+ }
1501
+
1502
+ if term.get("acronyms"):
1503
+ args["--acronym"] = term["acronyms"]
1504
+
1505
+ if term.get("owner_ids"):
1506
+ args["--owner-id"] = term["owner_ids"]
1507
+
1508
+ if term.get("resources"):
1509
+ args["--resource-name"] = [r["name"] for r in term["resources"]]
1510
+ args["--resource-url"] = [r["url"] for r in term["resources"]]
1511
+
1512
+ result = client.create_term(args)
1513
+
1514
+ # Check if result contains an ID (indicates successful creation)
1515
+ if result and isinstance(result, dict) and result.get("id"):
1516
+ success_count += 1
1517
+ term_id = result.get("id")
1518
+ console.print(f"[green]Created: {term['name']} (ID: {term_id})[/green]")
1519
+ elif result and not (isinstance(result, dict) and "error" in result):
1520
+ # Got a response but no ID - might be an issue
1521
+ console.print(f"[yellow]WARNING: Response received for {term['name']} but no ID returned[/yellow]")
1522
+ console.print(f"[dim]Response: {json.dumps(result, indent=2)[:200]}...[/dim]")
1523
+ failed_count += 1
1524
+ failed_terms.append({"name": term["name"], "error": "No ID in response"})
1525
+ else:
1526
+ failed_count += 1
1527
+ error_msg = result.get("error", "Unknown error") if isinstance(result, dict) else "No response"
1528
+ failed_terms.append({"name": term["name"], "error": error_msg})
1529
+ console.print(f"[red]FAILED: {term['name']} - {error_msg}[/red]")
1530
+
1531
+ except Exception as e:
1532
+ failed_count += 1
1533
+ failed_terms.append({"name": term["name"], "error": str(e)})
1534
+ console.print(f"[red]FAILED: {term['name']} - {str(e)}[/red]")
1535
+
1536
+ # Summary
1537
+ console.print("\n" + "="*60)
1538
+ console.print(f"[cyan]Import Summary:[/cyan]")
1539
+ console.print(f" Total terms: {len(terms)}")
1540
+ console.print(f" [green]Successfully created: {success_count}[/green]")
1541
+ console.print(f" [red]Failed: {failed_count}[/red]")
1542
+
1543
+ if failed_terms:
1544
+ console.print("\n[red]Failed Terms:[/red]")
1545
+ for ft in failed_terms:
1546
+ console.print(f" • {ft['name']}: {ft['error']}")
1547
+
1548
+ except Exception as e:
1549
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1550
+
1551
+
1552
+ @term.command(name="import-json")
1553
+ @click.option("--json-file", required=True, type=click.Path(exists=True), help="Path to JSON file with terms")
1554
+ @click.option("--dry-run", is_flag=True, help="Preview terms without creating them")
1555
+ def import_terms_from_json(json_file, dry_run):
1556
+ """Bulk import glossary terms from a JSON file.
1557
+
1558
+ JSON Format:
1559
+ [
1560
+ {
1561
+ "name": "Term Name",
1562
+ "description": "Description",
1563
+ "domain_id": "domain-guid",
1564
+ "status": "Draft",
1565
+ "acronyms": ["API", "REST"],
1566
+ "owner_ids": ["owner-guid-1"],
1567
+ "resources": [
1568
+ {"name": "Resource Name", "url": "https://example.com"}
1569
+ ]
1570
+ }
1571
+ ]
1572
+
1573
+ Each term must include domain_id.
1574
+ """
1575
+ try:
1576
+ client = UnifiedCatalogClient()
1577
+
1578
+ # Read and parse JSON
1579
+ with open(json_file, 'r', encoding='utf-8') as f:
1580
+ terms = json.load(f)
1581
+
1582
+ if not isinstance(terms, list):
1583
+ console.print("[red]ERROR:[/red] JSON file must contain an array of terms")
1584
+ return
1585
+
1586
+ if not terms:
1587
+ console.print("[yellow]No terms found in JSON file.[/yellow]")
1588
+ return
1589
+
1590
+ console.print(f"[cyan]Found {len(terms)} term(s) in JSON file[/cyan]")
1591
+
1592
+ if dry_run:
1593
+ console.print("\n[yellow]DRY RUN - Preview of terms to be created:[/yellow]\n")
1594
+ _format_json_output(terms)
1595
+ return
1596
+
1597
+ # Import terms
1598
+ success_count = 0
1599
+ failed_count = 0
1600
+ failed_terms = []
1601
+
1602
+ with console.status("[bold green]Importing terms...") as status:
1603
+ for i, term in enumerate(terms, 1):
1604
+ term_name = term.get("name", f"Term {i}")
1605
+ status.update(f"[bold green]Creating term {i}/{len(terms)}: {term_name}")
1606
+
1607
+ try:
1608
+ args = {
1609
+ "--name": [term.get("name", "")],
1610
+ "--description": [term.get("description", "")],
1611
+ "--governance-domain-id": [term.get("domain_id", "")],
1612
+ "--status": [term.get("status", "Draft")],
1613
+ }
1614
+
1615
+ if term.get("acronyms"):
1616
+ args["--acronym"] = term["acronyms"]
1617
+
1618
+ if term.get("owner_ids"):
1619
+ args["--owner-id"] = term["owner_ids"]
1620
+
1621
+ if term.get("resources"):
1622
+ args["--resource-name"] = [r.get("name", "") for r in term["resources"]]
1623
+ args["--resource-url"] = [r.get("url", "") for r in term["resources"]]
1624
+
1625
+ result = client.create_term(args)
1626
+
1627
+ # Check if result contains an ID (indicates successful creation)
1628
+ if result and isinstance(result, dict) and result.get("id"):
1629
+ success_count += 1
1630
+ term_id = result.get("id")
1631
+ console.print(f"[green]Created: {term_name} (ID: {term_id})[/green]")
1632
+ elif result and not (isinstance(result, dict) and "error" in result):
1633
+ # Got a response but no ID - might be an issue
1634
+ console.print(f"[yellow]WARNING: Response received for {term_name} but no ID returned[/yellow]")
1635
+ console.print(f"[dim]Response: {json.dumps(result, indent=2)[:200]}...[/dim]")
1636
+ failed_count += 1
1637
+ failed_terms.append({"name": term_name, "error": "No ID in response"})
1638
+ else:
1639
+ failed_count += 1
1640
+ error_msg = result.get("error", "Unknown error") if isinstance(result, dict) else "No response"
1641
+ failed_terms.append({"name": term_name, "error": error_msg})
1642
+ console.print(f"[red]FAILED: {term_name} - {error_msg}[/red]")
1643
+
1644
+ except Exception as e:
1645
+ failed_count += 1
1646
+ failed_terms.append({"name": term_name, "error": str(e)})
1647
+ console.print(f"[red]FAILED: {term_name} - {str(e)}[/red]")
1648
+
1649
+ # Summary
1650
+ console.print("\n" + "="*60)
1651
+ console.print(f"[cyan]Import Summary:[/cyan]")
1652
+ console.print(f" Total terms: {len(terms)}")
1653
+ console.print(f" [green]Successfully created: {success_count}[/green]")
1654
+ console.print(f" [red]Failed: {failed_count}[/red]")
1655
+
1656
+ if failed_terms:
1657
+ console.print("\n[red]Failed Terms:[/red]")
1658
+ for ft in failed_terms:
1659
+ console.print(f" • {ft['name']}: {ft['error']}")
1660
+
1661
+ except Exception as e:
1662
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1663
+
1664
+
1665
+ @term.command(name="update-csv")
1666
+ @click.option("--csv-file", required=True, type=click.Path(exists=True), help="Path to CSV file with term updates")
1667
+ @click.option("--dry-run", is_flag=True, help="Preview updates without applying them")
1668
+ def update_terms_from_csv(csv_file, dry_run):
1669
+ """Bulk update glossary terms from a CSV file.
1670
+
1671
+ CSV Format:
1672
+ term_id,name,description,status,parent_id,acronyms,owner_ids,add_acronyms,add_owner_ids
1673
+
1674
+ Required:
1675
+ - term_id: The ID of the term to update
1676
+
1677
+ Optional (leave empty to skip update):
1678
+ - name: New term name (replaces existing)
1679
+ - description: New description (replaces existing)
1680
+ - status: New status (Draft, Published, Archived)
1681
+ - parent_id: Parent term ID for hierarchical relationships (replaces existing)
1682
+ - acronyms: New acronyms separated by semicolons (replaces all existing)
1683
+ - owner_ids: New owner IDs separated by semicolons (replaces all existing)
1684
+ - add_acronyms: Acronyms to add separated by semicolons (preserves existing)
1685
+ - add_owner_ids: Owner IDs to add separated by semicolons (preserves existing)
1686
+
1687
+ Example CSV:
1688
+ term_id,name,description,status,parent_id,add_acronyms,add_owner_ids
1689
+ abc-123,,Updated description,Published,parent-term-guid,API;REST,user1@company.com
1690
+ def-456,New Name,,,parent-term-guid,SQL,
1691
+ """
1692
+ import csv
1693
+
1694
+ try:
1695
+ # Read CSV file
1696
+ with open(csv_file, 'r', encoding='utf-8') as f:
1697
+ reader = csv.DictReader(f)
1698
+ updates = list(reader)
1699
+
1700
+ if not updates:
1701
+ console.print("[yellow]No updates found in CSV file.[/yellow]")
1702
+ return
1703
+
1704
+ console.print(f"Found {len(updates)} term(s) to update in CSV file")
1705
+
1706
+ # Dry run preview
1707
+ if dry_run:
1708
+ console.print("\n[cyan]DRY RUN - Preview of updates to be applied:[/cyan]\n")
1709
+
1710
+ table = Table(title="Terms to Update")
1711
+ table.add_column("#", style="cyan")
1712
+ table.add_column("Term ID", style="yellow")
1713
+ table.add_column("Updates", style="white")
1714
+
1715
+ for idx, update in enumerate(updates, 1):
1716
+ term_id = update.get('term_id', '').strip()
1717
+ if not term_id:
1718
+ continue
1719
+
1720
+ changes = []
1721
+ if update.get('name', '').strip():
1722
+ changes.append(f"name: {update['name']}")
1723
+ if update.get('description', '').strip():
1724
+ changes.append(f"desc: {update['description'][:50]}...")
1725
+ if update.get('status', '').strip():
1726
+ changes.append(f"status: {update['status']}")
1727
+ if update.get('parent_id', '').strip():
1728
+ changes.append(f"parent: {update['parent_id'][:20]}...")
1729
+ if update.get('acronyms', '').strip():
1730
+ changes.append(f"acronyms: {update['acronyms']}")
1731
+ if update.get('add_acronyms', '').strip():
1732
+ changes.append(f"add acronyms: {update['add_acronyms']}")
1733
+ if update.get('owner_ids', '').strip():
1734
+ changes.append(f"owners: {update['owner_ids']}")
1735
+ if update.get('add_owner_ids', '').strip():
1736
+ changes.append(f"add owners: {update['add_owner_ids']}")
1737
+
1738
+ table.add_row(str(idx), term_id[:36], ", ".join(changes) if changes else "No changes")
1739
+
1740
+ console.print(table)
1741
+ console.print(f"\n[yellow]Total terms to update: {len(updates)}[/yellow]")
1742
+ return
1743
+
1744
+ # Apply updates
1745
+ console.print("\n[cyan]Updating terms...[/cyan]\n")
1746
+
1747
+ client = UnifiedCatalogClient()
1748
+ success_count = 0
1749
+ failed_count = 0
1750
+ failed_terms = []
1751
+
1752
+ for idx, update in enumerate(updates, 1):
1753
+ term_id = update.get('term_id', '').strip()
1754
+ if not term_id:
1755
+ console.print(f"[yellow]Skipping row {idx}: Missing term_id[/yellow]")
1756
+ continue
1757
+
1758
+ # Build update arguments
1759
+ args = {"--term-id": [term_id]}
1760
+
1761
+ # Add replace operations
1762
+ if update.get('name', '').strip():
1763
+ args['--name'] = [update['name'].strip()]
1764
+ if update.get('description', '').strip():
1765
+ args['--description'] = [update['description'].strip()]
1766
+ if update.get('status', '').strip():
1767
+ args['--status'] = [update['status'].strip()]
1768
+ if update.get('parent_id', '').strip():
1769
+ args['--parent-id'] = [update['parent_id'].strip()]
1770
+ if update.get('acronyms', '').strip():
1771
+ args['--acronym'] = [a.strip() for a in update['acronyms'].split(';') if a.strip()]
1772
+ if update.get('owner_ids', '').strip():
1773
+ args['--owner-id'] = [o.strip() for o in update['owner_ids'].split(';') if o.strip()]
1774
+
1775
+ # Add "add" operations
1776
+ if update.get('add_acronyms', '').strip():
1777
+ args['--add-acronym'] = [a.strip() for a in update['add_acronyms'].split(';') if a.strip()]
1778
+ if update.get('add_owner_ids', '').strip():
1779
+ args['--add-owner-id'] = [o.strip() for o in update['add_owner_ids'].split(';') if o.strip()]
1780
+
1781
+ # Display progress
1782
+ display_name = update.get('name', term_id[:36])
1783
+ console.status(f"[{idx}/{len(updates)}] Updating: {display_name}...")
1784
+
1785
+ try:
1786
+ result = client.update_term(args)
1787
+ console.print(f"[green]SUCCESS:[/green] Updated term {idx}/{len(updates)}")
1788
+ success_count += 1
1789
+ except Exception as e:
1790
+ error_msg = str(e)
1791
+ console.print(f"[red]FAILED:[/red] {display_name}: {error_msg}")
1792
+ failed_terms.append({'term_id': term_id, 'name': display_name, 'error': error_msg})
1793
+ failed_count += 1
1794
+
1795
+ # Rate limiting
1796
+ time.sleep(0.2)
1797
+
1798
+ # Summary
1799
+ console.print("\n" + "="*60)
1800
+ console.print(f"[cyan]Update Summary:[/cyan]")
1801
+ console.print(f" Total terms: {len(updates)}")
1802
+ console.print(f" [green]Successfully updated: {success_count}[/green]")
1803
+ console.print(f" [red]Failed: {failed_count}[/red]")
1804
+
1805
+ if failed_terms:
1806
+ console.print("\n[red]Failed Updates:[/red]")
1807
+ for ft in failed_terms:
1808
+ console.print(f" • {ft['name']}: {ft['error']}")
1809
+
1810
+ except Exception as e:
1811
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1812
+
1813
+
1814
+ @term.command(name="update-json")
1815
+ @click.option("--json-file", required=True, type=click.Path(exists=True), help="Path to JSON file with term updates")
1816
+ @click.option("--dry-run", is_flag=True, help="Preview updates without applying them")
1817
+ def update_terms_from_json(json_file, dry_run):
1818
+ """Bulk update glossary terms from a JSON file.
1819
+
1820
+ JSON Format:
1821
+ {
1822
+ "updates": [
1823
+ {
1824
+ "term_id": "term-guid",
1825
+ "name": "New Name", // Optional: Replace name
1826
+ "description": "New description", // Optional: Replace description
1827
+ "status": "Published", // Optional: Change status
1828
+ "parent_id": "parent-term-guid", // Optional: Set parent term (hierarchical)
1829
+ "acronyms": ["API", "REST"], // Optional: Replace all acronyms
1830
+ "owner_ids": ["user@company.com"], // Optional: Replace all owners
1831
+ "add_acronyms": ["SQL"], // Optional: Add acronyms (preserves existing)
1832
+ "add_owner_ids": ["user2@company.com"] // Optional: Add owners (preserves existing)
1833
+ }
1834
+ ]
1835
+ }
1836
+
1837
+ Note: Leave fields empty or omit them to skip that update.
1838
+ """
1839
+ import json
1840
+
1841
+ try:
1842
+ # Read JSON file
1843
+ with open(json_file, 'r', encoding='utf-8') as f:
1844
+ data = json.load(f)
1845
+
1846
+ updates = data.get('updates', [])
1847
+
1848
+ if not updates:
1849
+ console.print("[yellow]No updates found in JSON file.[/yellow]")
1850
+ return
1851
+
1852
+ console.print(f"Found {len(updates)} term(s) to update in JSON file")
1853
+
1854
+ # Dry run preview
1855
+ if dry_run:
1856
+ console.print("\n[cyan]DRY RUN - Preview of updates to be applied:[/cyan]\n")
1857
+
1858
+ # Display updates in colored JSON
1859
+ from rich.syntax import Syntax
1860
+ json_str = json.dumps(data, indent=2)
1861
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=True)
1862
+ console.print(syntax)
1863
+
1864
+ console.print(f"\n[yellow]Total terms to update: {len(updates)}[/yellow]")
1865
+ return
1866
+
1867
+ # Apply updates
1868
+ console.print("\n[cyan]Updating terms...[/cyan]\n")
1869
+
1870
+ client = UnifiedCatalogClient()
1871
+ success_count = 0
1872
+ failed_count = 0
1873
+ failed_terms = []
1874
+
1875
+ for idx, update in enumerate(updates, 1):
1876
+ term_id = update.get('term_id', '').strip() if isinstance(update.get('term_id'), str) else ''
1877
+ if not term_id:
1878
+ console.print(f"[yellow]Skipping update {idx}: Missing term_id[/yellow]")
1879
+ continue
1880
+
1881
+ # Build update arguments
1882
+ args = {"--term-id": [term_id]}
1883
+
1884
+ # Add replace operations
1885
+ if update.get('name'):
1886
+ args['--name'] = [update['name']]
1887
+ if update.get('description'):
1888
+ args['--description'] = [update['description']]
1889
+ if update.get('status'):
1890
+ args['--status'] = [update['status']]
1891
+ if update.get('parent_id'):
1892
+ args['--parent-id'] = [update['parent_id']]
1893
+ if update.get('acronyms'):
1894
+ args['--acronym'] = update['acronyms'] if isinstance(update['acronyms'], list) else [update['acronyms']]
1895
+ if update.get('owner_ids'):
1896
+ args['--owner-id'] = update['owner_ids'] if isinstance(update['owner_ids'], list) else [update['owner_ids']]
1897
+
1898
+ # Add "add" operations
1899
+ if update.get('add_acronyms'):
1900
+ args['--add-acronym'] = update['add_acronyms'] if isinstance(update['add_acronyms'], list) else [update['add_acronyms']]
1901
+ if update.get('add_owner_ids'):
1902
+ args['--add-owner-id'] = update['add_owner_ids'] if isinstance(update['add_owner_ids'], list) else [update['add_owner_ids']]
1903
+
1904
+ # Display progress
1905
+ display_name = update.get('name', term_id[:36])
1906
+ console.status(f"[{idx}/{len(updates)}] Updating: {display_name}...")
1907
+
1908
+ try:
1909
+ result = client.update_term(args)
1910
+ console.print(f"[green]SUCCESS:[/green] Updated term {idx}/{len(updates)}")
1911
+ success_count += 1
1912
+ except Exception as e:
1913
+ error_msg = str(e)
1914
+ console.print(f"[red]FAILED:[/red] {display_name}: {error_msg}")
1915
+ failed_terms.append({'term_id': term_id, 'name': display_name, 'error': error_msg})
1916
+ failed_count += 1
1917
+
1918
+ # Rate limiting
1919
+ time.sleep(0.2)
1920
+
1921
+ # Summary
1922
+ console.print("\n" + "="*60)
1923
+ console.print(f"[cyan]Update Summary:[/cyan]")
1924
+ console.print(f" Total terms: {len(updates)}")
1925
+ console.print(f" [green]Successfully updated: {success_count}[/green]")
1926
+ console.print(f" [red]Failed: {failed_count}[/red]")
1927
+
1928
+ if failed_terms:
1929
+ console.print("\n[red]Failed Updates:[/red]")
1930
+ for ft in failed_terms:
1931
+ console.print(f" • {ft['name']}: {ft['error']}")
1932
+
1933
+ except Exception as e:
1934
+ console.print(f"[red]ERROR:[/red] {str(e)}")
1935
+
1936
+
1937
+ @term.command(name="query")
1938
+ @click.option("--ids", multiple=True, help="Filter by specific term IDs (GUIDs)")
1939
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
1940
+ @click.option("--name-keyword", help="Filter by name keyword (partial match)")
1941
+ @click.option("--acronyms", multiple=True, help="Filter by acronyms")
1942
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
1943
+ @click.option("--status", type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
1944
+ help="Filter by status")
1945
+ @click.option("--multi-status", multiple=True,
1946
+ type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
1947
+ help="Filter by multiple statuses")
1948
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
1949
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
1950
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
1951
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
1952
+ help="Sort direction")
1953
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
1954
+ def query_terms(ids, domain_ids, name_keyword, acronyms, owners, status, multi_status,
1955
+ skip, top, order_by_field, order_by_direction, output):
1956
+ """Query terms with advanced filters.
1957
+
1958
+ Perform complex searches across glossary terms using multiple filter criteria.
1959
+ Supports pagination and custom sorting.
1960
+
1961
+ Examples:
1962
+ # Find all terms in a specific domain
1963
+ pvw uc term query --domain-ids <domain-guid>
1964
+
1965
+ # Search by keyword
1966
+ pvw uc term query --name-keyword "customer"
1967
+
1968
+ # Filter by acronym
1969
+ pvw uc term query --acronyms "PII" "GDPR"
1970
+
1971
+ # Filter by owner and status
1972
+ pvw uc term query --owners <user-guid> --status PUBLISHED
1973
+
1974
+ # Pagination example
1975
+ pvw uc term query --skip 0 --top 50 --order-by-field name --order-by-direction desc
1976
+ """
1977
+ try:
1978
+ client = UnifiedCatalogClient()
1979
+ args = {}
1980
+
1981
+ # Build args dict from parameters
1982
+ if ids:
1983
+ args["--ids"] = list(ids)
1984
+ if domain_ids:
1985
+ args["--domain-ids"] = list(domain_ids)
1986
+ if name_keyword:
1987
+ args["--name-keyword"] = [name_keyword]
1988
+ if acronyms:
1989
+ args["--acronyms"] = list(acronyms)
1990
+ if owners:
1991
+ args["--owners"] = list(owners)
1992
+ if status:
1993
+ args["--status"] = [status]
1994
+ if multi_status:
1995
+ args["--multi-status"] = list(multi_status)
1996
+ if skip:
1997
+ args["--skip"] = [str(skip)]
1998
+ if top:
1999
+ args["--top"] = [str(top)]
2000
+ if order_by_field:
2001
+ args["--order-by-field"] = [order_by_field]
2002
+ args["--order-by-direction"] = [order_by_direction]
2003
+
2004
+ result = client.query_terms(args)
2005
+
2006
+ if output == "json":
2007
+ console.print_json(data=result)
2008
+ else:
2009
+ terms = result.get("value", []) if result else []
2010
+
2011
+ if not terms:
2012
+ console.print("[yellow]No terms found matching the query.[/yellow]")
2013
+ return
2014
+
2015
+ # Check for pagination
2016
+ next_link = result.get("nextLink")
2017
+ if next_link:
2018
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2019
+
2020
+ table = Table(title=f"Query Results ({len(terms)} found)", show_header=True)
2021
+ table.add_column("Name", style="cyan")
2022
+ table.add_column("ID", style="dim", no_wrap=True)
2023
+ table.add_column("Domain", style="yellow", no_wrap=True)
2024
+ table.add_column("Status", style="white")
2025
+ table.add_column("Acronyms", style="green")
2026
+
2027
+ for term in terms:
2028
+ acronyms_list = term.get("acronyms", [])
2029
+ acronyms_display = ", ".join(acronyms_list[:2]) if acronyms_list else "N/A"
2030
+ if len(acronyms_list) > 2:
2031
+ acronyms_display += f" +{len(acronyms_list) - 2}"
2032
+
2033
+ table.add_row(
2034
+ term.get("name", "N/A"),
2035
+ term.get("id", "N/A")[:13] + "...",
2036
+ term.get("domain", "N/A")[:13] + "...",
2037
+ term.get("status", "N/A"),
2038
+ acronyms_display
2039
+ )
2040
+
2041
+ console.print(table)
2042
+
2043
+ # Show pagination info
2044
+ if skip > 0 or next_link:
2045
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(terms)}[/dim]")
2046
+
2047
+ except Exception as e:
2048
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2049
+
2050
+
2051
+ # ========================================
2052
+ # OBJECTIVES AND KEY RESULTS (OKRs)
2053
+ # ========================================
2054
+
2055
+
2056
+ @uc.group()
2057
+ def objective():
2058
+ """Manage objectives and key results (OKRs)."""
2059
+ pass
2060
+
2061
+
2062
+ @objective.command()
2063
+ @click.option("--definition", required=True, help="Definition of the objective")
2064
+ @click.option("--domain-id", required=True, help="Governance domain ID")
2065
+ @click.option(
2066
+ "--status",
2067
+ required=False,
2068
+ default="Draft",
2069
+ type=click.Choice(["Draft", "Published", "Archived"]),
2070
+ help="Status of the objective",
2071
+ )
2072
+ @click.option(
2073
+ "--owner-id",
2074
+ required=False,
2075
+ help="Owner Entra ID (can be specified multiple times)",
2076
+ multiple=True,
2077
+ )
2078
+ @click.option(
2079
+ "--target-date", required=False, help="Target date (ISO format: 2025-12-30T14:00:00.000Z)"
2080
+ )
2081
+ def create(definition, domain_id, status, owner_id, target_date):
2082
+ """Create a new objective."""
2083
+ try:
2084
+ client = UnifiedCatalogClient()
2085
+
2086
+ args = {
2087
+ "--definition": [definition],
2088
+ "--governance-domain-id": [domain_id],
2089
+ "--status": [status],
2090
+ }
2091
+
2092
+ if owner_id:
2093
+ args["--owner-id"] = list(owner_id)
2094
+ if target_date:
2095
+ args["--target-date"] = [target_date]
2096
+
2097
+ result = client.create_objective(args)
2098
+
2099
+ if not result:
2100
+ console.print("[red]ERROR:[/red] No response received")
2101
+ return
2102
+ if isinstance(result, dict) and "error" in result:
2103
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2104
+ return
2105
+
2106
+ console.print(f"[green] SUCCESS:[/green] Created objective")
2107
+ console.print(json.dumps(result, indent=2))
2108
+
2109
+ except Exception as e:
2110
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2111
+
2112
+
2113
+ @objective.command(name="list")
2114
+ @click.option("--domain-id", required=True, help="Governance domain ID to list objectives from")
2115
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
2116
+ def list_objectives(domain_id, output_json):
2117
+ """List all objectives in a governance domain."""
2118
+ try:
2119
+ client = UnifiedCatalogClient()
2120
+ args = {"--governance-domain-id": [domain_id]}
2121
+ result = client.get_objectives(args)
2122
+
2123
+ if not result:
2124
+ console.print("[yellow]No objectives found.[/yellow]")
2125
+ return
2126
+
2127
+ # Handle response format
2128
+ if isinstance(result, (list, tuple)):
2129
+ objectives = result
2130
+ elif isinstance(result, dict):
2131
+ objectives = result.get("value", [])
2132
+ else:
2133
+ objectives = []
2134
+
2135
+ if not objectives:
2136
+ console.print("[yellow]No objectives found.[/yellow]")
2137
+ return
2138
+
2139
+ # Output in JSON format if requested
2140
+ if output_json:
2141
+ _format_json_output(objectives)
2142
+ return
2143
+
2144
+ table = Table(title="Objectives")
2145
+ table.add_column("ID", style="cyan")
2146
+ table.add_column("Definition", style="green")
2147
+ table.add_column("Status", style="yellow")
2148
+ table.add_column("Target Date", style="blue")
2149
+
2150
+ for obj in objectives:
2151
+ definition = obj.get("definition", "")
2152
+ if len(definition) > 50:
2153
+ definition = definition[:50] + "..."
2154
+
2155
+ table.add_row(
2156
+ obj.get("id", "N/A"),
2157
+ definition,
2158
+ obj.get("status", "N/A"),
2159
+ obj.get("targetDate", "N/A"),
2160
+ )
2161
+
2162
+ console.print(table)
2163
+
2164
+ except Exception as e:
2165
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2166
+
2167
+
2168
+ @objective.command()
2169
+ @click.option("--objective-id", required=True, help="ID of the objective")
2170
+ def show(objective_id):
2171
+ """Show details of an objective."""
2172
+ try:
2173
+ client = UnifiedCatalogClient()
2174
+ args = {"--objective-id": [objective_id]}
2175
+ result = client.get_objective_by_id(args)
2176
+
2177
+ if not result:
2178
+ console.print("[red]ERROR:[/red] No response received")
2179
+ return
2180
+ if isinstance(result, dict) and "error" in result:
2181
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Objective not found')}")
2182
+ return
2183
+
2184
+ console.print(json.dumps(result, indent=2))
2185
+
2186
+ except Exception as e:
2187
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2188
+
2189
+
2190
+ @objective.command(name="query")
2191
+ @click.option("--ids", multiple=True, help="Filter by specific objective IDs (GUIDs)")
2192
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
2193
+ @click.option("--definition", help="Filter by definition text (partial match)")
2194
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
2195
+ @click.option("--status", type=click.Choice(["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"], case_sensitive=False),
2196
+ help="Filter by status")
2197
+ @click.option("--multi-status", multiple=True,
2198
+ type=click.Choice(["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"], case_sensitive=False),
2199
+ help="Filter by multiple statuses")
2200
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
2201
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
2202
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
2203
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
2204
+ help="Sort direction")
2205
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2206
+ def query_objectives(ids, domain_ids, definition, owners, status, multi_status,
2207
+ skip, top, order_by_field, order_by_direction, output):
2208
+ """Query objectives with advanced filters.
2209
+
2210
+ Perform complex searches across OKR objectives using multiple filter criteria.
2211
+ Supports pagination and custom sorting.
2212
+
2213
+ Examples:
2214
+ # Find all objectives in a specific domain
2215
+ pvw uc objective query --domain-ids <domain-guid>
2216
+
2217
+ # Search by definition text
2218
+ pvw uc objective query --definition "customer satisfaction"
2219
+
2220
+ # Filter by owner and status
2221
+ pvw uc objective query --owners <user-guid> --status ACTIVE
2222
+
2223
+ # Find all completed objectives
2224
+ pvw uc objective query --multi-status COMPLETED ARCHIVED
2225
+
2226
+ # Pagination example
2227
+ pvw uc objective query --skip 0 --top 50 --order-by-field name --order-by-direction asc
2228
+ """
2229
+ try:
2230
+ client = UnifiedCatalogClient()
2231
+ args = {}
2232
+
2233
+ # Build args dict from parameters
2234
+ if ids:
2235
+ args["--ids"] = list(ids)
2236
+ if domain_ids:
2237
+ args["--domain-ids"] = list(domain_ids)
2238
+ if definition:
2239
+ args["--definition"] = [definition]
2240
+ if owners:
2241
+ args["--owners"] = list(owners)
2242
+ if status:
2243
+ args["--status"] = [status]
2244
+ if multi_status:
2245
+ args["--multi-status"] = list(multi_status)
2246
+ if skip:
2247
+ args["--skip"] = [str(skip)]
2248
+ if top:
2249
+ args["--top"] = [str(top)]
2250
+ if order_by_field:
2251
+ args["--order-by-field"] = [order_by_field]
2252
+ args["--order-by-direction"] = [order_by_direction]
2253
+
2254
+ result = client.query_objectives(args)
2255
+
2256
+ if output == "json":
2257
+ console.print_json(data=result)
2258
+ else:
2259
+ objectives = result.get("value", []) if result else []
2260
+
2261
+ if not objectives:
2262
+ console.print("[yellow]No objectives found matching the query.[/yellow]")
2263
+ return
2264
+
2265
+ # Check for pagination
2266
+ next_link = result.get("nextLink")
2267
+ if next_link:
2268
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2269
+
2270
+ table = Table(title=f"Query Results ({len(objectives)} found)", show_header=True)
2271
+ table.add_column("Name", style="cyan")
2272
+ table.add_column("ID", style="dim", no_wrap=True)
2273
+ table.add_column("Domain", style="yellow", no_wrap=True)
2274
+ table.add_column("Status", style="white")
2275
+ table.add_column("Owner", style="green", no_wrap=True)
2276
+
2277
+ for obj in objectives:
2278
+ owner_display = "N/A"
2279
+ if obj.get("owner"):
2280
+ owner_display = obj["owner"].get("id", "N/A")[:13] + "..."
2281
+
2282
+ table.add_row(
2283
+ obj.get("name", "N/A"),
2284
+ obj.get("id", "N/A")[:13] + "...",
2285
+ obj.get("domain", "N/A")[:13] + "...",
2286
+ obj.get("status", "N/A"),
2287
+ owner_display
2288
+ )
2289
+
2290
+ console.print(table)
2291
+
2292
+ # Show pagination info
2293
+ if skip > 0 or next_link:
2294
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(objectives)}[/dim]")
2295
+
2296
+ except Exception as e:
2297
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2298
+
2299
+
2300
+ # ========================================
2301
+ # CRITICAL DATA ELEMENTS (CDEs)
2302
+ # ========================================
2303
+
2304
+
2305
+ @uc.group()
2306
+ def cde():
2307
+ """Manage critical data elements."""
2308
+ pass
2309
+
2310
+
2311
+ @cde.command()
2312
+ @click.option("--name", required=True, help="Name of the critical data element")
2313
+ @click.option("--description", required=False, default="", help="Description of the CDE")
2314
+ @click.option("--domain-id", required=True, help="Governance domain ID")
2315
+ @click.option(
2316
+ "--data-type",
2317
+ required=True,
2318
+ type=click.Choice(["String", "Number", "Boolean", "Date", "DateTime"]),
2319
+ help="Data type of the CDE",
2320
+ )
2321
+ @click.option(
2322
+ "--status",
2323
+ required=False,
2324
+ default="Draft",
2325
+ type=click.Choice(["Draft", "Published", "Archived"]),
2326
+ help="Status of the CDE",
2327
+ )
2328
+ @click.option(
2329
+ "--owner-id",
2330
+ required=False,
2331
+ help="Owner Entra ID (can be specified multiple times)",
2332
+ multiple=True,
2333
+ )
2334
+ def create(name, description, domain_id, data_type, status, owner_id):
2335
+ """Create a new critical data element."""
2336
+ try:
2337
+ client = UnifiedCatalogClient()
2338
+
2339
+ args = {
2340
+ "--name": [name],
2341
+ "--description": [description],
2342
+ "--governance-domain-id": [domain_id],
2343
+ "--data-type": [data_type],
2344
+ "--status": [status],
2345
+ }
2346
+
2347
+ if owner_id:
2348
+ args["--owner-id"] = list(owner_id)
2349
+
2350
+ result = client.create_critical_data_element(args)
2351
+
2352
+ if not result:
2353
+ console.print("[red]ERROR:[/red] No response received")
2354
+ return
2355
+ if isinstance(result, dict) and "error" in result:
2356
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2357
+ return
2358
+
2359
+ console.print(f"[green] SUCCESS:[/green] Created critical data element '{name}'")
2360
+ console.print(json.dumps(result, indent=2))
2361
+
2362
+ except Exception as e:
2363
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2364
+
2365
+
2366
+ @cde.command(name="list")
2367
+ @click.option("--domain-id", required=True, help="Governance domain ID to list CDEs from")
2368
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
2369
+ def list_cdes(domain_id, output_json):
2370
+ """List all critical data elements in a governance domain."""
2371
+ try:
2372
+ client = UnifiedCatalogClient()
2373
+ args = {"--governance-domain-id": [domain_id]}
2374
+ result = client.get_critical_data_elements(args)
2375
+
2376
+ if not result:
2377
+ console.print("[yellow]No critical data elements found.[/yellow]")
2378
+ return
2379
+
2380
+ # Handle response format
2381
+ if isinstance(result, (list, tuple)):
2382
+ cdes = result
2383
+ elif isinstance(result, dict):
2384
+ cdes = result.get("value", [])
2385
+ else:
2386
+ cdes = []
2387
+
2388
+ if not cdes:
2389
+ console.print("[yellow]No critical data elements found.[/yellow]")
2390
+ return
2391
+
2392
+ # Output in JSON format if requested
2393
+ if output_json:
2394
+ _format_json_output(cdes)
2395
+ return
2396
+
2397
+ table = Table(title="Critical Data Elements")
2398
+ table.add_column("ID", style="cyan")
2399
+ table.add_column("Name", style="green")
2400
+ table.add_column("Data Type", style="blue")
2401
+ table.add_column("Status", style="yellow")
2402
+ table.add_column("Description", style="white")
2403
+
2404
+ for cde_item in cdes:
2405
+ desc = cde_item.get("description", "")
2406
+ if len(desc) > 30:
2407
+ desc = desc[:30] + "..."
2408
+
2409
+ table.add_row(
2410
+ cde_item.get("id", "N/A"),
2411
+ cde_item.get("name", "N/A"),
2412
+ cde_item.get("dataType", "N/A"),
2413
+ cde_item.get("status", "N/A"),
2414
+ desc,
2415
+ )
2416
+
2417
+ console.print(table)
2418
+
2419
+ except Exception as e:
2420
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2421
+
2422
+
2423
+ @cde.command()
2424
+ @click.option("--cde-id", required=True, help="ID of the critical data element")
2425
+ def show(cde_id):
2426
+ """Show details of a critical data element."""
2427
+ try:
2428
+ client = UnifiedCatalogClient()
2429
+ args = {"--cde-id": [cde_id]}
2430
+ result = client.get_critical_data_element_by_id(args)
2431
+
2432
+ if not result:
2433
+ console.print("[red]ERROR:[/red] No response received")
2434
+ return
2435
+ if isinstance(result, dict) and "error" in result:
2436
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'CDE not found')}")
2437
+ return
2438
+
2439
+ console.print(json.dumps(result, indent=2))
2440
+
2441
+ except Exception as e:
2442
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2443
+
2444
+
2445
+ @cde.command(name="add-relationship")
2446
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2447
+ @click.option("--entity-type", required=True,
2448
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2449
+ help="Type of entity to relate to")
2450
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to relate to")
2451
+ @click.option("--asset-id", help="Asset ID (GUID) - defaults to entity-id if not provided")
2452
+ @click.option("--relationship-type", default="Related", help="Relationship type (default: Related)")
2453
+ @click.option("--description", default="", help="Description of the relationship")
2454
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2455
+ def add_cde_relationship(cde_id, entity_type, entity_id, asset_id, relationship_type, description, output):
2456
+ """Create a relationship for a critical data element.
2457
+
2458
+ Links a CDE to another entity like a critical data column, term, or data product.
2459
+
2460
+ Examples:
2461
+ pvw uc cde add-relationship --cde-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
2462
+ pvw uc cde add-relationship --cde-id <id> --entity-type TERM --entity-id <term-id> --description "Primary term"
2463
+ """
2464
+ try:
2465
+ client = UnifiedCatalogClient()
2466
+ args = {
2467
+ "--cde-id": [cde_id],
2468
+ "--entity-type": [entity_type],
2469
+ "--entity-id": [entity_id],
2470
+ "--relationship-type": [relationship_type],
2471
+ "--description": [description]
2472
+ }
2473
+
2474
+ if asset_id:
2475
+ args["--asset-id"] = [asset_id]
2476
+
2477
+ result = client.create_cde_relationship(args)
2478
+
2479
+ if output == "json":
2480
+ console.print_json(data=result)
2481
+ else:
2482
+ if result and isinstance(result, dict):
2483
+ console.print("[green]SUCCESS:[/green] Created CDE relationship")
2484
+ table = Table(title="CDE Relationship", show_header=True)
2485
+ table.add_column("Property", style="cyan")
2486
+ table.add_column("Value", style="white")
2487
+
2488
+ table.add_row("Entity ID", result.get("entityId", "N/A"))
2489
+ table.add_row("Relationship Type", result.get("relationshipType", "N/A"))
2490
+ table.add_row("Description", result.get("description", "N/A"))
2491
+
2492
+ if "systemData" in result:
2493
+ sys_data = result["systemData"]
2494
+ table.add_row("Created By", sys_data.get("createdBy", "N/A"))
2495
+ table.add_row("Created At", sys_data.get("createdAt", "N/A"))
2496
+
2497
+ console.print(table)
2498
+ else:
2499
+ console.print("[green]SUCCESS:[/green] Created CDE relationship")
2500
+
2501
+ except Exception as e:
2502
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2503
+
2504
+
2505
+ @cde.command(name="list-relationships")
2506
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2507
+ @click.option("--entity-type",
2508
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2509
+ help="Filter by entity type (optional)")
2510
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2511
+ def list_cde_relationships(cde_id, entity_type, output):
2512
+ """List relationships for a critical data element.
2513
+
2514
+ Shows all entities linked to this CDE, optionally filtered by type.
2515
+
2516
+ Examples:
2517
+ pvw uc cde list-relationships --cde-id <id>
2518
+ pvw uc cde list-relationships --cde-id <id> --entity-type CRITICALDATACOLUMN
2519
+ """
2520
+ try:
2521
+ client = UnifiedCatalogClient()
2522
+ args = {"--cde-id": [cde_id]}
2523
+
2524
+ if entity_type:
2525
+ args["--entity-type"] = [entity_type]
2526
+
2527
+ result = client.get_cde_relationships(args)
2528
+
2529
+ if output == "json":
2530
+ console.print_json(data=result)
2531
+ else:
2532
+ relationships = result.get("value", []) if result else []
2533
+
2534
+ if not relationships:
2535
+ console.print(f"[yellow]No relationships found for CDE '{cde_id}'[/yellow]")
2536
+ return
2537
+
2538
+ table = Table(title=f"CDE Relationships ({len(relationships)} found)", show_header=True)
2539
+ table.add_column("Entity ID", style="cyan")
2540
+ table.add_column("Relationship Type", style="white")
2541
+ table.add_column("Description", style="white")
2542
+ table.add_column("Created", style="dim")
2543
+
2544
+ for rel in relationships:
2545
+ table.add_row(
2546
+ rel.get("entityId", "N/A"),
2547
+ rel.get("relationshipType", "N/A"),
2548
+ rel.get("description", "")[:50] + ("..." if len(rel.get("description", "")) > 50 else ""),
2549
+ rel.get("systemData", {}).get("createdAt", "N/A")[:10]
2550
+ )
2551
+
2552
+ console.print(table)
2553
+
2554
+ except Exception as e:
2555
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2556
+
2557
+
2558
+ @cde.command(name="remove-relationship")
2559
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2560
+ @click.option("--entity-type", required=True,
2561
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2562
+ help="Type of entity to unlink")
2563
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to unlink")
2564
+ @click.option("--confirm/--no-confirm", default=True, help="Ask for confirmation before deleting")
2565
+ def remove_cde_relationship(cde_id, entity_type, entity_id, confirm):
2566
+ """Delete a relationship between a CDE and an entity.
2567
+
2568
+ Removes the link between a critical data element and a specific entity.
2569
+
2570
+ Examples:
2571
+ pvw uc cde remove-relationship --cde-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
2572
+ pvw uc cde remove-relationship --cde-id <id> --entity-type TERM --entity-id <term-id> --no-confirm
2573
+ """
2574
+ try:
2575
+ if confirm:
2576
+ confirm = click.confirm(
2577
+ f"Are you sure you want to delete CDE relationship to {entity_type} '{entity_id}'?",
2578
+ default=False
2579
+ )
2580
+ if not confirm:
2581
+ console.print("[yellow]Deletion cancelled.[/yellow]")
2582
+ return
2583
+
2584
+ client = UnifiedCatalogClient()
2585
+ args = {
2586
+ "--cde-id": [cde_id],
2587
+ "--entity-type": [entity_type],
2588
+ "--entity-id": [entity_id]
2589
+ }
2590
+
2591
+ result = client.delete_cde_relationship(args)
2592
+
2593
+ # DELETE returns 204 No Content on success
2594
+ if result is None or (isinstance(result, dict) and not result.get("error")):
2595
+ console.print(f"[green]SUCCESS:[/green] Deleted CDE relationship to {entity_type} '{entity_id}'")
2596
+ elif isinstance(result, dict) and "error" in result:
2597
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2598
+ else:
2599
+ console.print(f"[green]SUCCESS:[/green] Deleted CDE relationship")
2600
+
2601
+ except Exception as e:
2602
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2603
+
2604
+
2605
+ @cde.command(name="query")
2606
+ @click.option("--ids", multiple=True, help="Filter by specific CDE IDs (GUIDs)")
2607
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
2608
+ @click.option("--name-keyword", help="Filter by name keyword (partial match)")
2609
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
2610
+ @click.option("--status", type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
2611
+ help="Filter by status")
2612
+ @click.option("--multi-status", multiple=True,
2613
+ type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
2614
+ help="Filter by multiple statuses")
2615
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
2616
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
2617
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
2618
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
2619
+ help="Sort direction")
2620
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2621
+ def query_cdes(ids, domain_ids, name_keyword, owners, status, multi_status,
2622
+ skip, top, order_by_field, order_by_direction, output):
2623
+ """Query critical data elements with advanced filters.
2624
+
2625
+ Perform complex searches across CDEs using multiple filter criteria.
2626
+ Supports pagination and custom sorting.
2627
+
2628
+ Examples:
2629
+ # Find all CDEs in a specific domain
2630
+ pvw uc cde query --domain-ids <domain-guid>
2631
+
2632
+ # Search by keyword
2633
+ pvw uc cde query --name-keyword "customer"
2634
+
2635
+ # Filter by owner and status
2636
+ pvw uc cde query --owners <user-guid> --status PUBLISHED
2637
+
2638
+ # Find all published or expired CDEs
2639
+ pvw uc cde query --multi-status PUBLISHED EXPIRED
2640
+
2641
+ # Pagination example
2642
+ pvw uc cde query --skip 0 --top 50 --order-by-field name --order-by-direction desc
2643
+ """
2644
+ try:
2645
+ client = UnifiedCatalogClient()
2646
+ args = {}
2647
+
2648
+ # Build args dict from parameters
2649
+ if ids:
2650
+ args["--ids"] = list(ids)
2651
+ if domain_ids:
2652
+ args["--domain-ids"] = list(domain_ids)
2653
+ if name_keyword:
2654
+ args["--name-keyword"] = [name_keyword]
2655
+ if owners:
2656
+ args["--owners"] = list(owners)
2657
+ if status:
2658
+ args["--status"] = [status]
2659
+ if multi_status:
2660
+ args["--multi-status"] = list(multi_status)
2661
+ if skip:
2662
+ args["--skip"] = [str(skip)]
2663
+ if top:
2664
+ args["--top"] = [str(top)]
2665
+ if order_by_field:
2666
+ args["--order-by-field"] = [order_by_field]
2667
+ args["--order-by-direction"] = [order_by_direction]
2668
+
2669
+ result = client.query_critical_data_elements(args)
2670
+
2671
+ if output == "json":
2672
+ console.print_json(data=result)
2673
+ else:
2674
+ cdes = result.get("value", []) if result else []
2675
+
2676
+ if not cdes:
2677
+ console.print("[yellow]No critical data elements found matching the query.[/yellow]")
2678
+ return
2679
+
2680
+ # Check for pagination
2681
+ next_link = result.get("nextLink")
2682
+ if next_link:
2683
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2684
+
2685
+ table = Table(title=f"Query Results ({len(cdes)} found)", show_header=True)
2686
+ table.add_column("Name", style="cyan")
2687
+ table.add_column("ID", style="dim", no_wrap=True)
2688
+ table.add_column("Domain", style="yellow", no_wrap=True)
2689
+ table.add_column("Status", style="white")
2690
+ table.add_column("Data Type", style="green")
2691
+
2692
+ for cde in cdes:
2693
+ table.add_row(
2694
+ cde.get("name", "N/A"),
2695
+ cde.get("id", "N/A")[:13] + "...",
2696
+ cde.get("domain", "N/A")[:13] + "...",
2697
+ cde.get("status", "N/A"),
2698
+ cde.get("dataType", "N/A")
2699
+ )
2700
+
2701
+ console.print(table)
2702
+
2703
+ # Show pagination info
2704
+ if skip > 0 or next_link:
2705
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(cdes)}[/dim]")
2706
+
2707
+ except Exception as e:
2708
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2709
+
2710
+
2711
+ # ========================================
2712
+ # KEY RESULTS (OKRs)
2713
+ # ========================================
2714
+
2715
+
2716
+ @uc.group()
2717
+ def keyresult():
2718
+ """Manage key results for objectives (OKRs)."""
2719
+ pass
2720
+
2721
+
2722
+ @keyresult.command(name="list")
2723
+ @click.option("--objective-id", required=True, help="Objective ID to list key results for")
2724
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
2725
+ def list_key_results(objective_id, output_json):
2726
+ """List all key results for an objective."""
2727
+ try:
2728
+ client = UnifiedCatalogClient()
2729
+ args = {"--objective-id": [objective_id]}
2730
+ result = client.get_key_results(args)
2731
+
2732
+ if not result:
2733
+ console.print("[yellow]No key results found.[/yellow]")
2734
+ return
2735
+
2736
+ # Handle response format
2737
+ if isinstance(result, (list, tuple)):
2738
+ key_results = result
2739
+ elif isinstance(result, dict):
2740
+ key_results = result.get("value", [])
2741
+ else:
2742
+ key_results = []
2743
+
2744
+ if not key_results:
2745
+ console.print("[yellow]No key results found.[/yellow]")
2746
+ return
2747
+
2748
+ # Output in JSON format if requested
2749
+ if output_json:
2750
+ _format_json_output(key_results)
2751
+ return
2752
+
2753
+ table = Table(title=f"Key Results for Objective {objective_id[:8]}...")
2754
+ table.add_column("ID", style="cyan", no_wrap=True)
2755
+ table.add_column("Definition", style="green", max_width=50)
2756
+ table.add_column("Progress", style="blue")
2757
+ table.add_column("Goal", style="yellow")
2758
+ table.add_column("Max", style="magenta")
2759
+ table.add_column("Status", style="white")
2760
+
2761
+ for kr in key_results:
2762
+ definition = kr.get("definition", "N/A")
2763
+ if len(definition) > 47:
2764
+ definition = definition[:47] + "..."
2765
+
2766
+ table.add_row(
2767
+ kr.get("id", "N/A")[:13] + "...",
2768
+ definition,
2769
+ str(kr.get("progress", "N/A")),
2770
+ str(kr.get("goal", "N/A")),
2771
+ str(kr.get("max", "N/A")),
2772
+ kr.get("status", "N/A"),
2773
+ )
2774
+
2775
+ console.print(table)
2776
+ console.print(f"\n[dim]Found {len(key_results)} key result(s)[/dim]")
2777
+
2778
+ except Exception as e:
2779
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2780
+
2781
+
2782
+ @keyresult.command()
2783
+ @click.option("--objective-id", required=True, help="Objective ID")
2784
+ @click.option("--key-result-id", required=True, help="Key result ID")
2785
+ def show(objective_id, key_result_id):
2786
+ """Show details of a key result."""
2787
+ try:
2788
+ client = UnifiedCatalogClient()
2789
+ args = {
2790
+ "--objective-id": [objective_id],
2791
+ "--key-result-id": [key_result_id]
2792
+ }
2793
+ result = client.get_key_result_by_id(args)
2794
+
2795
+ if not result:
2796
+ console.print("[red]ERROR:[/red] No response received")
2797
+ return
2798
+ if isinstance(result, dict) and "error" in result:
2799
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Key result not found')}")
2800
+ return
2801
+
2802
+ console.print(json.dumps(result, indent=2))
2803
+
2804
+ except Exception as e:
2805
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2806
+
2807
+
2808
+ @keyresult.command()
2809
+ @click.option("--objective-id", required=True, help="Objective ID")
2810
+ @click.option("--governance-domain-id", required=True, help="Governance domain ID")
2811
+ @click.option("--definition", required=True, help="Definition/description of the key result")
2812
+ @click.option("--progress", required=False, type=int, default=0, help="Current progress value (default: 0)")
2813
+ @click.option("--goal", required=True, type=int, help="Target goal value")
2814
+ @click.option("--max", "max_value", required=False, type=int, default=100, help="Maximum possible value (default: 100)")
2815
+ @click.option(
2816
+ "--status",
2817
+ required=False,
2818
+ default="OnTrack",
2819
+ type=click.Choice(["OnTrack", "AtRisk", "OffTrack", "Completed"]),
2820
+ help="Status of the key result",
2821
+ )
2822
+ def create(objective_id, governance_domain_id, definition, progress, goal, max_value, status):
2823
+ """Create a new key result for an objective."""
2824
+ try:
2825
+ client = UnifiedCatalogClient()
2826
+
2827
+ args = {
2828
+ "--objective-id": [objective_id],
2829
+ "--governance-domain-id": [governance_domain_id],
2830
+ "--definition": [definition],
2831
+ "--progress": [str(progress)],
2832
+ "--goal": [str(goal)],
2833
+ "--max": [str(max_value)],
2834
+ "--status": [status],
2835
+ }
2836
+
2837
+ result = client.create_key_result(args)
2838
+
2839
+ if not result:
2840
+ console.print("[red]ERROR:[/red] No response received")
2841
+ return
2842
+ if isinstance(result, dict) and "error" in result:
2843
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2844
+ return
2845
+
2846
+ console.print(f"[green]SUCCESS:[/green] Created key result")
2847
+ console.print(json.dumps(result, indent=2))
2848
+
2849
+ except Exception as e:
2850
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2851
+
2852
+
2853
+ @keyresult.command()
2854
+ @click.option("--objective-id", required=True, help="Objective ID")
2855
+ @click.option("--key-result-id", required=True, help="Key result ID to update")
2856
+ @click.option("--governance-domain-id", required=False, help="Governance domain ID")
2857
+ @click.option("--definition", required=False, help="New definition/description")
2858
+ @click.option("--progress", required=False, type=int, help="New progress value")
2859
+ @click.option("--goal", required=False, type=int, help="New goal value")
2860
+ @click.option("--max", "max_value", required=False, type=int, help="New maximum value")
2861
+ @click.option(
2862
+ "--status",
2863
+ required=False,
2864
+ type=click.Choice(["OnTrack", "AtRisk", "OffTrack", "Completed"]),
2865
+ help="Status of the key result",
2866
+ )
2867
+ def update(objective_id, key_result_id, governance_domain_id, definition, progress, goal, max_value, status):
2868
+ """Update an existing key result."""
2869
+ try:
2870
+ client = UnifiedCatalogClient()
2871
+
2872
+ # Build args dictionary - only include provided values
2873
+ args = {
2874
+ "--objective-id": [objective_id],
2875
+ "--key-result-id": [key_result_id]
2876
+ }
2877
+
2878
+ if governance_domain_id:
2879
+ args["--governance-domain-id"] = [governance_domain_id]
2880
+ if definition:
2881
+ args["--definition"] = [definition]
2882
+ if progress is not None:
2883
+ args["--progress"] = [str(progress)]
2884
+ if goal is not None:
2885
+ args["--goal"] = [str(goal)]
2886
+ if max_value is not None:
2887
+ args["--max"] = [str(max_value)]
2888
+ if status:
2889
+ args["--status"] = [status]
2890
+
2891
+ result = client.update_key_result(args)
2892
+
2893
+ if not result:
2894
+ console.print("[red]ERROR:[/red] No response received")
2895
+ return
2896
+ if isinstance(result, dict) and "error" in result:
2897
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2898
+ return
2899
+
2900
+ console.print(f"[green]SUCCESS:[/green] Updated key result '{key_result_id}'")
2901
+ console.print(json.dumps(result, indent=2))
2902
+
2903
+ except Exception as e:
2904
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2905
+
2906
+
2907
+ @keyresult.command()
2908
+ @click.option("--objective-id", required=True, help="Objective ID")
2909
+ @click.option("--key-result-id", required=True, help="Key result ID to delete")
2910
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
2911
+ def delete(objective_id, key_result_id, yes):
2912
+ """Delete a key result."""
2913
+ try:
2914
+ if not yes:
2915
+ confirm = click.confirm(
2916
+ f"Are you sure you want to delete key result '{key_result_id}'?",
2917
+ default=False
2918
+ )
2919
+ if not confirm:
2920
+ console.print("[yellow]Deletion cancelled.[/yellow]")
2921
+ return
2922
+
2923
+ client = UnifiedCatalogClient()
2924
+ args = {
2925
+ "--objective-id": [objective_id],
2926
+ "--key-result-id": [key_result_id]
2927
+ }
2928
+ result = client.delete_key_result(args)
2929
+
2930
+ # DELETE operations may return empty response on success
2931
+ if result is None or (isinstance(result, dict) and not result.get("error")):
2932
+ console.print(f"[green]SUCCESS:[/green] Deleted key result '{key_result_id}'")
2933
+ elif isinstance(result, dict) and "error" in result:
2934
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2935
+ else:
2936
+ console.print(f"[green]SUCCESS:[/green] Deleted key result")
2937
+ if result:
2938
+ console.print(json.dumps(result, indent=2))
2939
+
2940
+ except Exception as e:
2941
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2942
+
2943
+
2944
+ # ========================================
2945
+ # HEALTH MANAGEMENT - IMPLEMENTED!
2946
+ # ========================================
2947
+
2948
+ # Import and register health commands from dedicated module
2949
+ from purviewcli.cli.health import health as health_commands
2950
+ uc.add_command(health_commands, name="health")
2951
+
2952
+
2953
+ # ========================================
2954
+ # DATA POLICIES (NEW)
2955
+ # ========================================
2956
+
2957
+
2958
+ @uc.group()
2959
+ def policy():
2960
+ """Manage data governance policies."""
2961
+ pass
2962
+
2963
+
2964
+ @policy.command(name="list")
2965
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
2966
+ def list_policies(output):
2967
+ """List all data governance policies."""
2968
+ client = UnifiedCatalogClient()
2969
+ response = client.list_policies({})
2970
+
2971
+ if output == "json":
2972
+ console.print_json(json.dumps(response))
2973
+ else:
2974
+ # API returns 'values' (plural), not 'value'
2975
+ policies = response.get("values", response.get("value", []))
2976
+
2977
+ if policies:
2978
+ table = Table(title="[bold cyan]Data Governance Policies[/bold cyan]", show_header=True)
2979
+ table.add_column("ID", style="cyan", no_wrap=True)
2980
+ table.add_column("Name", style="green")
2981
+ table.add_column("Entity Type", style="yellow")
2982
+ table.add_column("Entity ID", style="magenta", no_wrap=True)
2983
+ table.add_column("Rules", style="white")
2984
+
2985
+ for item in policies:
2986
+ props = item.get("properties", {})
2987
+ entity = props.get("entity", {})
2988
+ entity_type = entity.get("type", "N/A")
2989
+ entity_ref = entity.get("referenceName", "N/A")
2990
+
2991
+ # Count rules
2992
+ decision_rules = len(props.get("decisionRules", []))
2993
+ attribute_rules = len(props.get("attributeRules", []))
2994
+ rules_summary = f"{decision_rules} decision, {attribute_rules} attribute"
2995
+
2996
+ table.add_row(
2997
+ item.get("id", "N/A")[:36], # Show only GUID
2998
+ item.get("name", "N/A"),
2999
+ entity_type.replace("Reference", ""), # Clean up type name
3000
+ entity_ref[:36], # Show only GUID
3001
+ rules_summary
3002
+ )
3003
+
3004
+ console.print(table)
3005
+ console.print(f"\n[dim]Total: {len(policies)} policy/policies[/dim]")
3006
+ else:
3007
+ console.print("[yellow]No policies found[/yellow]")
3008
+
3009
+
3010
+ @policy.command(name="get")
3011
+ @click.option("--policy-id", required=True, help="Policy ID")
3012
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3013
+ def get_policy(policy_id, output):
3014
+ """Get a specific data governance policy by ID."""
3015
+ client = UnifiedCatalogClient()
3016
+
3017
+ # Get all policies and filter (since GET by ID returns 404)
3018
+ all_policies = client.list_policies({})
3019
+ policies = all_policies.get("values", all_policies.get("value", []))
3020
+
3021
+ # Find the requested policy
3022
+ policy = next((p for p in policies if p.get("id") == policy_id), None)
3023
+
3024
+ if not policy:
3025
+ console.print(f"[red]ERROR:[/red] Policy with ID {policy_id} not found")
3026
+ return
3027
+
3028
+ if output == "json":
3029
+ _format_json_output(policy)
3030
+ else:
3031
+ # Display policy in formatted view
3032
+ props = policy.get("properties", {})
3033
+ entity = props.get("entity", {})
3034
+
3035
+ console.print(f"\n[bold cyan]Policy Details[/bold cyan]")
3036
+ console.print(f"[bold]ID:[/bold] {policy.get('id')}")
3037
+ console.print(f"[bold]Name:[/bold] {policy.get('name')}")
3038
+ console.print(f"[bold]Version:[/bold] {policy.get('version', 0)}")
3039
+
3040
+ console.print(f"\n[bold cyan]Entity[/bold cyan]")
3041
+ console.print(f"[bold]Type:[/bold] {entity.get('type', 'N/A')}")
3042
+ console.print(f"[bold]Reference:[/bold] {entity.get('referenceName', 'N/A')}")
3043
+ console.print(f"[bold]Parent:[/bold] {props.get('parentEntityName', 'N/A')}")
3044
+
3045
+ # Decision Rules
3046
+ decision_rules = props.get("decisionRules", [])
3047
+ if decision_rules:
3048
+ console.print(f"\n[bold cyan]Decision Rules ({len(decision_rules)})[/bold cyan]")
3049
+ for i, rule in enumerate(decision_rules, 1):
3050
+ console.print(f"\n [bold]Rule {i}:[/bold] {rule.get('kind', 'N/A')}")
3051
+ console.print(f" [bold]Effect:[/bold] {rule.get('effect', 'N/A')}")
3052
+ if "dnfCondition" in rule:
3053
+ console.print(f" [bold]Conditions:[/bold] {len(rule['dnfCondition'])} clause(s)")
3054
+
3055
+ # Attribute Rules
3056
+ attribute_rules = props.get("attributeRules", [])
3057
+ if attribute_rules:
3058
+ console.print(f"\n[bold cyan]Attribute Rules ({len(attribute_rules)})[/bold cyan]")
3059
+ for i, rule in enumerate(attribute_rules, 1):
3060
+ console.print(f"\n [bold]Rule {i}:[/bold] {rule.get('name', rule.get('id', 'N/A'))}")
3061
+ if "dnfCondition" in rule:
3062
+ conditions = rule.get("dnfCondition", [])
3063
+ console.print(f" [bold]Conditions:[/bold] {len(conditions)} clause(s)")
3064
+ for j, clause in enumerate(conditions[:3], 1): # Show first 3
3065
+ if clause:
3066
+ attr = clause[0] if isinstance(clause, list) else clause
3067
+ console.print(f" {j}. {attr.get('attributeName', 'N/A')}")
3068
+ if len(conditions) > 3:
3069
+ console.print(f" ... and {len(conditions) - 3} more")
3070
+
3071
+ console.print()
3072
+
3073
+
3074
+
3075
+ @policy.command(name="create")
3076
+ @click.option("--name", required=True, help="Policy name")
3077
+ @click.option("--policy-type", required=True, help="Policy type (e.g., access, retention)")
3078
+ @click.option("--description", default="", help="Policy description")
3079
+ @click.option("--status", default="active", help="Policy status (active, draft)")
3080
+ def create_policy(name, policy_type, description, status):
3081
+ """Create a new data governance policy."""
3082
+ client = UnifiedCatalogClient()
3083
+ args = {
3084
+ "--name": [name],
3085
+ "--policy-type": [policy_type],
3086
+ "--description": [description],
3087
+ "--status": [status]
3088
+ }
3089
+ response = client.create_policy(args)
3090
+
3091
+ console.print(f"[green]SUCCESS:[/green] Policy created")
3092
+ _format_json_output(response)
3093
+
3094
+
3095
+ @policy.command(name="update")
3096
+ @click.option("--policy-id", required=True, help="Policy ID")
3097
+ @click.option("--name", help="New policy name")
3098
+ @click.option("--description", help="New policy description")
3099
+ @click.option("--status", help="New policy status")
3100
+ def update_policy(policy_id, name, description, status):
3101
+ """Update an existing data governance policy."""
3102
+ client = UnifiedCatalogClient()
3103
+ args = {"--policy-id": [policy_id]}
3104
+
3105
+ if name:
3106
+ args["--name"] = [name]
3107
+ if description:
3108
+ args["--description"] = [description]
3109
+ if status:
3110
+ args["--status"] = [status]
3111
+
3112
+ response = client.update_policy(args)
3113
+
3114
+ console.print(f"[green]SUCCESS:[/green] Policy updated")
3115
+ _format_json_output(response)
3116
+
3117
+
3118
+ @policy.command(name="delete")
3119
+ @click.option("--policy-id", required=True, help="Policy ID")
3120
+ @click.confirmation_option(prompt="Are you sure you want to delete this policy?")
3121
+ def delete_policy(policy_id):
3122
+ """Delete a data governance policy."""
3123
+ client = UnifiedCatalogClient()
3124
+ args = {"--policy-id": [policy_id]}
3125
+ response = client.delete_policy(args)
3126
+
3127
+ console.print(f"[green]SUCCESS:[/green] Policy '{policy_id}' deleted")
3128
+
3129
+
3130
+ # ========================================
3131
+ # CUSTOM METADATA (NEW)
3132
+ # ========================================
3133
+
3134
+
3135
+ @uc.group()
3136
+ def metadata():
3137
+ """Manage custom metadata for assets."""
3138
+ pass
3139
+
3140
+
3141
+ @metadata.command(name="list")
3142
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
3143
+ @click.option("--fallback/--no-fallback", default=True, help="Fallback to Business Metadata if UC is empty")
3144
+ def list_custom_metadata(output, fallback):
3145
+ """List all custom metadata definitions.
3146
+
3147
+ Uses Atlas API to get Business Metadata definitions.
3148
+ With fallback enabled, shows user-friendly table format.
3149
+ """
3150
+ client = UnifiedCatalogClient()
3151
+ response = client.list_custom_metadata({})
3152
+
3153
+ # Check if UC API returned business metadata (Atlas returns businessMetadataDefs)
3154
+ has_uc_data = (response and "businessMetadataDefs" in response
3155
+ and response["businessMetadataDefs"])
3156
+
3157
+ if output == "json":
3158
+ if has_uc_data:
3159
+ console.print_json(json.dumps(response))
3160
+ elif fallback:
3161
+ # Fallback message (though Atlas API should always return something)
3162
+ console.print("[dim]No business metadata found.[/dim]\n")
3163
+ console.print_json(json.dumps({"businessMetadataDefs": []}))
3164
+ else:
3165
+ console.print_json(json.dumps(response))
3166
+ else:
3167
+ # Table output
3168
+ if has_uc_data:
3169
+ biz_metadata = response.get('businessMetadataDefs', [])
3170
+
3171
+ if biz_metadata:
3172
+ table = Table(title="[bold green]Business Metadata Attributes[/bold green]", show_header=True)
3173
+ table.add_column("Attribute Name", style="green", no_wrap=True)
3174
+ table.add_column("Group", style="cyan")
3175
+ table.add_column("Type", style="yellow")
3176
+ table.add_column("Scope", style="magenta", max_width=25)
3177
+ table.add_column("Description", style="white", max_width=30)
3178
+
3179
+ total_attrs = 0
3180
+ for group in biz_metadata:
3181
+ group_name = group.get('name', 'N/A')
3182
+ attributes = group.get('attributeDefs', [])
3183
+
3184
+ # Parse group-level scope
3185
+ group_scope = "N/A"
3186
+ options = group.get('options', {})
3187
+ if 'dataGovernanceOptions' in options:
3188
+ try:
3189
+ dg_opts_str = options.get('dataGovernanceOptions', '{}')
3190
+ dg_opts = json.loads(dg_opts_str) if isinstance(dg_opts_str, str) else dg_opts_str
3191
+ applicable = dg_opts.get('applicableConstructs', [])
3192
+ if applicable:
3193
+ # Categorize scope
3194
+ has_business_concept = any('businessConcept' in c or 'domain' in c for c in applicable)
3195
+ has_dataset = any('dataset' in c.lower() for c in applicable)
3196
+
3197
+ if has_business_concept and has_dataset:
3198
+ group_scope = "Universal (Concept + Dataset)"
3199
+ elif has_business_concept:
3200
+ group_scope = "Business Concept"
3201
+ elif has_dataset:
3202
+ group_scope = "Data Asset"
3203
+ else:
3204
+ # Show first 2 constructs
3205
+ scope_parts = []
3206
+ for construct in applicable[:2]:
3207
+ if ':' in construct:
3208
+ scope_parts.append(construct.split(':')[0])
3209
+ else:
3210
+ scope_parts.append(construct)
3211
+ group_scope = ', '.join(scope_parts)
3212
+ except:
3213
+ pass
3214
+
3215
+ for attr in attributes:
3216
+ total_attrs += 1
3217
+ attr_name = attr.get('name', 'N/A')
3218
+ attr_type = attr.get('typeName', 'N/A')
3219
+
3220
+ # Simplify enum types
3221
+ if 'ATTRIBUTE_ENUM_' in attr_type:
3222
+ attr_type = 'Enum'
3223
+
3224
+ attr_desc = attr.get('description', '')
3225
+
3226
+ # Check if attribute has custom scope
3227
+ attr_scope = group_scope
3228
+ attr_opts = attr.get('options', {})
3229
+
3230
+ # Check dataGovernanceOptions first
3231
+ if 'dataGovernanceOptions' in attr_opts:
3232
+ try:
3233
+ attr_dg_str = attr_opts.get('dataGovernanceOptions', '{}')
3234
+ attr_dg = json.loads(attr_dg_str) if isinstance(attr_dg_str, str) else attr_dg_str
3235
+ inherit = attr_dg.get('inheritApplicableConstructsFromGroup', True)
3236
+ if not inherit:
3237
+ attr_applicable = attr_dg.get('applicableConstructs', [])
3238
+ if attr_applicable:
3239
+ # Categorize custom scope
3240
+ has_business_concept = any('businessConcept' in c or 'domain' in c for c in attr_applicable)
3241
+ has_dataset = any('dataset' in c.lower() for c in attr_applicable)
3242
+
3243
+ if has_business_concept and has_dataset:
3244
+ attr_scope = "Universal"
3245
+ elif has_business_concept:
3246
+ attr_scope = "Business Concept"
3247
+ elif has_dataset:
3248
+ attr_scope = "Data Asset"
3249
+ else:
3250
+ attr_scope = f"Custom ({len(attr_applicable)})"
3251
+ except:
3252
+ pass
3253
+
3254
+ # Fallback: Check applicableEntityTypes (older format)
3255
+ if attr_scope == "N/A" and 'applicableEntityTypes' in attr_opts:
3256
+ try:
3257
+ entity_types_str = attr_opts.get('applicableEntityTypes', '[]')
3258
+ # Parse if string, otherwise use as-is
3259
+ if isinstance(entity_types_str, str):
3260
+ entity_types = json.loads(entity_types_str)
3261
+ else:
3262
+ entity_types = entity_types_str
3263
+
3264
+ if entity_types and isinstance(entity_types, list):
3265
+ # Check if entity types are data assets (tables, etc.)
3266
+ if any('table' in et.lower() or 'database' in et.lower() or 'file' in et.lower()
3267
+ for et in entity_types):
3268
+ attr_scope = "Data Asset"
3269
+ else:
3270
+ attr_scope = f"Assets ({len(entity_types)} types)"
3271
+ except Exception as e:
3272
+ # Silently fail but could log for debugging
3273
+ pass
3274
+
3275
+ table.add_row(
3276
+ attr_name,
3277
+ group_name,
3278
+ attr_type,
3279
+ attr_scope,
3280
+ attr_desc[:30] + "..." if len(attr_desc) > 30 else attr_desc
3281
+ )
3282
+
3283
+ console.print(table)
3284
+ console.print(f"\n[cyan]Total:[/cyan] {total_attrs} business metadata attribute(s) in {len(biz_metadata)} group(s)")
3285
+ console.print("\n[dim]Legend:[/dim]")
3286
+ console.print(" [magenta]Business Concept[/magenta] = Applies to Terms, Domains, Business Rules")
3287
+ console.print(" [magenta]Data Asset[/magenta] = Applies to Tables, Files, Databases")
3288
+ console.print(" [magenta]Universal[/magenta] = Applies to both Concepts and Assets")
3289
+ else:
3290
+ console.print("[yellow]No business metadata found[/yellow]")
3291
+ else:
3292
+ console.print("[yellow]No business metadata found[/yellow]")
3293
+
3294
+
3295
+ @metadata.command(name="get")
3296
+ @click.option("--asset-id", required=True, help="Asset GUID")
3297
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3298
+ def get_custom_metadata(asset_id, output):
3299
+ """Get custom metadata (business metadata) for a specific asset."""
3300
+ client = UnifiedCatalogClient()
3301
+ args = {"--asset-id": [asset_id]}
3302
+ response = client.get_custom_metadata(args)
3303
+
3304
+ if output == "json":
3305
+ # Extract businessAttributes from entity response
3306
+ # Note: API returns "businessAttributes" not "businessMetadata"
3307
+ if response and "entity" in response:
3308
+ business_metadata = response["entity"].get("businessAttributes", {})
3309
+ _format_json_output(business_metadata)
3310
+ elif response and isinstance(response, dict):
3311
+ business_metadata = response.get("businessAttributes", {})
3312
+ _format_json_output(business_metadata)
3313
+ else:
3314
+ _format_json_output({})
3315
+ else:
3316
+ table = Table(title=f"[bold cyan]Business Metadata for Asset: {asset_id}[/bold cyan]")
3317
+ table.add_column("Group", style="cyan")
3318
+ table.add_column("Attribute", style="green")
3319
+ table.add_column("Value", style="white")
3320
+
3321
+ if response and "entity" in response:
3322
+ business_metadata = response["entity"].get("businessAttributes", {})
3323
+ if business_metadata:
3324
+ for group_name, attributes in business_metadata.items():
3325
+ if isinstance(attributes, dict):
3326
+ for attr_name, attr_value in attributes.items():
3327
+ table.add_row(group_name, attr_name, str(attr_value))
3328
+ elif response and isinstance(response, dict):
3329
+ business_metadata = response.get("businessAttributes", {})
3330
+ if business_metadata:
3331
+ for group_name, attributes in business_metadata.items():
3332
+ if isinstance(attributes, dict):
3333
+ for attr_name, attr_value in attributes.items():
3334
+ table.add_row(group_name, attr_name, str(attr_value))
3335
+
3336
+ console.print(table)
3337
+
3338
+
3339
+ @metadata.command(name="add")
3340
+ @click.option("--asset-id", required=True, help="Asset GUID")
3341
+ @click.option("--group", required=True, help="Business metadata group name (e.g., 'Governance', 'Privacy')")
3342
+ @click.option("--key", required=True, help="Attribute name")
3343
+ @click.option("--value", required=True, help="Attribute value")
3344
+ def add_custom_metadata(asset_id, group, key, value):
3345
+ """Add custom metadata (business metadata) to an asset.
3346
+
3347
+ Example: pvw uc metadata add --asset-id <guid> --group Governance --key DataOwner --value "John Doe"
3348
+ """
3349
+ client = UnifiedCatalogClient()
3350
+ args = {
3351
+ "--asset-id": [asset_id],
3352
+ "--group": [group],
3353
+ "--key": [key],
3354
+ "--value": [value]
3355
+ }
3356
+ response = client.add_custom_metadata(args)
3357
+
3358
+ console.print(f"[green]SUCCESS:[/green] Business metadata '{key}' added to group '{group}' on asset '{asset_id}'")
3359
+ if response:
3360
+ _format_json_output(response)
3361
+
3362
+
3363
+ @metadata.command(name="update")
3364
+ @click.option("--asset-id", required=True, help="Asset GUID")
3365
+ @click.option("--group", required=True, help="Business metadata group name")
3366
+ @click.option("--key", required=True, help="Attribute name to update")
3367
+ @click.option("--value", required=True, help="New attribute value")
3368
+ def update_custom_metadata(asset_id, group, key, value):
3369
+ """Update custom metadata (business metadata) for an asset.
3370
+
3371
+ Example: pvw uc metadata update --asset-id <guid> --group Governance --key DataOwner --value "Jane Smith"
3372
+ """
3373
+ client = UnifiedCatalogClient()
3374
+ args = {
3375
+ "--asset-id": [asset_id],
3376
+ "--group": [group],
3377
+ "--key": [key],
3378
+ "--value": [value]
3379
+ }
3380
+ response = client.update_custom_metadata(args)
3381
+
3382
+ console.print(f"[green]SUCCESS:[/green] Business metadata '{key}' updated in group '{group}' on asset '{asset_id}'")
3383
+ if response:
3384
+ _format_json_output(response)
3385
+
3386
+
3387
+ @metadata.command(name="delete")
3388
+ @click.option("--asset-id", required=True, help="Asset GUID")
3389
+ @click.option("--group", required=True, help="Business metadata group name to delete")
3390
+ @click.confirmation_option(prompt="Are you sure you want to delete this business metadata group?")
3391
+ def delete_custom_metadata(asset_id, group):
3392
+ """Delete custom metadata (business metadata) from an asset.
3393
+
3394
+ This removes the entire business metadata group from the asset.
3395
+ Example: pvw uc metadata delete --asset-id <guid> --group Governance
3396
+ """
3397
+ client = UnifiedCatalogClient()
3398
+ args = {
3399
+ "--asset-id": [asset_id],
3400
+ "--group": [group]
3401
+ }
3402
+ response = client.delete_custom_metadata(args)
3403
+
3404
+ console.print(f"[green]SUCCESS:[/green] Business metadata group '{group}' deleted from asset '{asset_id}'")
3405
+
3406
+
3407
+ # ========================================
3408
+ # CUSTOM ATTRIBUTES (NEW)
3409
+ # ========================================
3410
+
3411
+
3412
+ @uc.group()
3413
+ def attribute():
3414
+ """Manage custom attribute definitions."""
3415
+ pass
3416
+
3417
+
3418
+ @attribute.command(name="list")
3419
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
3420
+ def list_custom_attributes(output):
3421
+ """List all custom attribute definitions."""
3422
+ client = UnifiedCatalogClient()
3423
+ response = client.list_custom_attributes({})
3424
+
3425
+ if output == "json":
3426
+ console.print_json(json.dumps(response))
3427
+ else:
3428
+ if "value" in response and response["value"]:
3429
+ table = Table(title="[bold cyan]Custom Attribute Definitions[/bold cyan]", show_header=True)
3430
+ table.add_column("ID", style="cyan")
3431
+ table.add_column("Name", style="green")
3432
+ table.add_column("Data Type", style="yellow")
3433
+ table.add_column("Required", style="magenta")
3434
+ table.add_column("Description", style="white")
3435
+
3436
+ for item in response["value"]:
3437
+ table.add_row(
3438
+ item.get("id", "N/A"),
3439
+ item.get("name", "N/A"),
3440
+ item.get("dataType", "N/A"),
3441
+ "Yes" if item.get("required") else "No",
3442
+ item.get("description", "")[:50] + "..." if len(item.get("description", "")) > 50 else item.get("description", "")
3443
+ )
3444
+ console.print(table)
3445
+ else:
3446
+ console.print("[yellow]No custom attributes found[/yellow]")
3447
+
3448
+
3449
+ @attribute.command(name="get")
3450
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3451
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3452
+ def get_custom_attribute(attribute_id, output):
3453
+ """Get a specific custom attribute definition."""
3454
+ client = UnifiedCatalogClient()
3455
+ args = {"--attribute-id": [attribute_id]}
3456
+ response = client.get_custom_attribute(args)
3457
+
3458
+ if output == "json":
3459
+ _format_json_output(response)
3460
+ else:
3461
+ table = Table(title=f"[bold cyan]Attribute: {response.get('name', 'N/A')}[/bold cyan]")
3462
+ table.add_column("Property", style="cyan")
3463
+ table.add_column("Value", style="white")
3464
+
3465
+ for key, value in response.items():
3466
+ table.add_row(key, str(value))
3467
+ console.print(table)
3468
+
3469
+
3470
+ @attribute.command(name="create")
3471
+ @click.option("--name", required=True, help="Attribute name")
3472
+ @click.option("--data-type", required=True, help="Data type (string, number, boolean, date)")
3473
+ @click.option("--description", default="", help="Attribute description")
3474
+ @click.option("--required", is_flag=True, help="Is this attribute required?")
3475
+ def create_custom_attribute(name, data_type, description, required):
3476
+ """Create a new custom attribute definition."""
3477
+ client = UnifiedCatalogClient()
3478
+ args = {
3479
+ "--name": [name],
3480
+ "--data-type": [data_type],
3481
+ "--description": [description],
3482
+ "--required": ["true" if required else "false"]
3483
+ }
3484
+ response = client.create_custom_attribute(args)
3485
+
3486
+ console.print(f"[green]SUCCESS:[/green] Custom attribute created")
3487
+ _format_json_output(response)
3488
+
3489
+
3490
+ @attribute.command(name="update")
3491
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3492
+ @click.option("--name", help="New attribute name")
3493
+ @click.option("--description", help="New attribute description")
3494
+ @click.option("--required", type=bool, help="Is this attribute required? (true/false)")
3495
+ def update_custom_attribute(attribute_id, name, description, required):
3496
+ """Update an existing custom attribute definition."""
3497
+ client = UnifiedCatalogClient()
3498
+ args = {"--attribute-id": [attribute_id]}
3499
+
3500
+ if name:
3501
+ args["--name"] = [name]
3502
+ if description:
3503
+ args["--description"] = [description]
3504
+ if required is not None:
3505
+ args["--required"] = ["true" if required else "false"]
3506
+
3507
+ response = client.update_custom_attribute(args)
3508
+
3509
+ console.print(f"[green]SUCCESS:[/green] Custom attribute updated")
3510
+ _format_json_output(response)
3511
+
3512
+
3513
+ @attribute.command(name="delete")
3514
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3515
+ @click.confirmation_option(prompt="Are you sure you want to delete this attribute?")
3516
+ def delete_custom_attribute(attribute_id):
3517
+ """Delete a custom attribute definition."""
3518
+ client = UnifiedCatalogClient()
3519
+ args = {"--attribute-id": [attribute_id]}
3520
+ response = client.delete_custom_attribute(args)
3521
+
3522
+ console.print(f"[green]SUCCESS:[/green] Custom attribute '{attribute_id}' deleted")
3523
+
3524
+
3525
+ # ========================================
3526
+ # REQUESTS (Coming Soon)
3527
+ # ========================================
3528
+
3529
+
3530
+ @uc.group()
3531
+ def request():
3532
+ """Manage access requests (coming soon)."""
3533
+ pass
3534
+
3535
+
3536
+ @request.command(name="list")
3537
+ def list_requests():
3538
+ """List access requests (coming soon)."""
3539
+ console.print("[yellow]🚧 Access Requests are coming soon[/yellow]")
3540
+ console.print("This feature is under development for data access workflows")