pvw-cli 1.3.3__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.
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 +3796 -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.3.3.dist-info/METADATA +1618 -0
  57. pvw_cli-1.3.3.dist-info/RECORD +60 -0
  58. pvw_cli-1.3.3.dist-info/WHEEL +5 -0
  59. pvw_cli-1.3.3.dist-info/entry_points.txt +3 -0
  60. pvw_cli-1.3.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3796 @@
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] 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
+ @term.command(name="sync-classic")
2052
+ @click.option("--domain-id", required=False, help="Governance domain ID to sync terms from (if not provided, syncs all domains)")
2053
+ @click.option("--glossary-guid", required=False, help="Target classic glossary GUID (if not provided, creates/uses glossary with domain name)")
2054
+ @click.option("--create-glossary", is_flag=True, help="Create classic glossary if it doesn't exist")
2055
+ @click.option("--dry-run", is_flag=True, help="Preview changes without applying them")
2056
+ @click.option("--update-existing", is_flag=True, help="Update existing classic terms if they already exist")
2057
+ def sync_classic(domain_id, glossary_guid, create_glossary, dry_run, update_existing):
2058
+ """Synchronize Unified Catalog terms to classic glossary terms.
2059
+
2060
+ This command bridges the Unified Catalog (business metadata) with classic glossaries,
2061
+ enabling you to sync terms from governance domains to traditional glossary structures.
2062
+
2063
+ Examples:
2064
+ # Sync all terms from a specific domain to its corresponding glossary
2065
+ pvw uc term sync-classic --domain-id <domain-guid>
2066
+
2067
+ # Sync to a specific glossary
2068
+ pvw uc term sync-classic --domain-id <domain-guid> --glossary-guid <glossary-guid>
2069
+
2070
+ # Create glossary if needed and sync
2071
+ pvw uc term sync-classic --domain-id <domain-guid> --create-glossary
2072
+
2073
+ # Preview sync without making changes
2074
+ pvw uc term sync-classic --domain-id <domain-guid> --dry-run
2075
+
2076
+ # Update existing terms in classic glossary
2077
+ pvw uc term sync-classic --domain-id <domain-guid> --update-existing
2078
+ """
2079
+ try:
2080
+ from purviewcli.client._glossary import Glossary
2081
+ import tempfile
2082
+ import traceback
2083
+
2084
+ uc_client = UnifiedCatalogClient()
2085
+ glossary_client = Glossary()
2086
+
2087
+ console.print("[cyan]" + "-" * 59 + "[/cyan]")
2088
+ console.print("[bold cyan] Unified Catalog → Classic Glossary Sync [/bold cyan]")
2089
+ console.print("[cyan]" + "-" * 59 + "[/cyan]\n")
2090
+
2091
+ if dry_run:
2092
+ console.print("[yellow][*] DRY RUN MODE - No changes will be made[/yellow]\n")
2093
+
2094
+ # Step 1: Get UC terms
2095
+ console.print("[bold]Step 1:[/bold] Fetching Unified Catalog terms...")
2096
+ uc_args = {}
2097
+ if domain_id:
2098
+ uc_args["--governance-domain-id"] = [domain_id]
2099
+
2100
+ uc_result = uc_client.get_terms(uc_args)
2101
+
2102
+ # Extract terms from response
2103
+ uc_terms = []
2104
+ if isinstance(uc_result, dict):
2105
+ uc_terms = uc_result.get("value", [])
2106
+ elif isinstance(uc_result, (list, tuple)):
2107
+ uc_terms = uc_result
2108
+
2109
+ if not uc_terms:
2110
+ console.print("[yellow][!] No Unified Catalog terms found.[/yellow]")
2111
+ return
2112
+
2113
+ console.print(f"[green][OK][/green] Found {len(uc_terms)} UC term(s)\n")
2114
+
2115
+ # Step 2: Determine or create target glossary
2116
+ console.print("[bold]Step 2:[/bold] Determining target glossary...")
2117
+
2118
+ target_glossary_guid = glossary_guid
2119
+
2120
+ if not target_glossary_guid:
2121
+ # Get domain info to use domain name as glossary name
2122
+ if domain_id:
2123
+ domain_info = uc_client.get_governance_domain_by_id({"--domain-id": [domain_id]})
2124
+ domain_name = domain_info.get("name", "Unknown Domain")
2125
+ console.print(f" Domain: [cyan]{domain_name}[/cyan]")
2126
+
2127
+ # Try to find existing glossary with matching name
2128
+ all_glossaries = glossary_client.glossaryRead({})
2129
+ if isinstance(all_glossaries, dict):
2130
+ all_glossaries = all_glossaries.get("value", [])
2131
+
2132
+ for g in all_glossaries:
2133
+ g_name = g.get("name", "")
2134
+ g_qualified = g.get("qualifiedName", "")
2135
+
2136
+ # Check multiple formats for compatibility:
2137
+ # 1. Exact name match
2138
+ # 2. Standard format: DomainName@Glossary
2139
+ # 3. Old format (for backward compatibility): DomainName@domain-id
2140
+ if (g_name == domain_name or
2141
+ g_qualified == f"{domain_name}@Glossary" or
2142
+ g_qualified == f"{domain_name}@{domain_id}"):
2143
+ target_glossary_guid = g.get("guid")
2144
+ console.print(f"[green][OK][/green] Found existing glossary: {g_name} ({target_glossary_guid})\n")
2145
+ break
2146
+
2147
+ if not target_glossary_guid and create_glossary:
2148
+ if dry_run:
2149
+ console.print(f"[yellow]Would create glossary:[/yellow] {domain_name}\n")
2150
+ else:
2151
+ # Create glossary with proper qualifiedName format
2152
+ # Format: GlossaryName@Glossary (standard Purview format)
2153
+ qualified_name = f"{domain_name}@Glossary"
2154
+
2155
+ glossary_payload = {
2156
+ "name": domain_name,
2157
+ "qualifiedName": qualified_name,
2158
+ "shortDescription": f"Auto-synced from Unified Catalog domain: {domain_name}",
2159
+ "longDescription": f"This glossary is automatically synchronized with the Unified Catalog governance domain '{domain_name}' (ID: {domain_id})"
2160
+ }
2161
+
2162
+ import tempfile
2163
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
2164
+ json.dump(glossary_payload, f)
2165
+ temp_file = f.name
2166
+
2167
+ try:
2168
+ new_glossary = glossary_client.glossaryCreate({"--payloadFile": temp_file})
2169
+ target_glossary_guid = new_glossary.get("guid")
2170
+ console.print(f"[green][OK][/green] Created glossary: {domain_name} ({target_glossary_guid})\n")
2171
+ finally:
2172
+ os.unlink(temp_file)
2173
+ elif not target_glossary_guid:
2174
+ console.print(f"[red]ERROR:[/red] No target glossary found. Use --glossary-guid or --create-glossary")
2175
+ return
2176
+ else:
2177
+ console.print("[red]ERROR:[/red] Either --domain-id or --glossary-guid must be provided")
2178
+ return
2179
+ else:
2180
+ console.print(f"[green][OK][/green] Using target glossary: {target_glossary_guid}\n")
2181
+
2182
+ # Step 3: Get existing classic glossary terms
2183
+ console.print("[bold]Step 3:[/bold] Checking existing classic glossary terms...")
2184
+
2185
+ existing_terms = {}
2186
+ try:
2187
+ glossary_details = glossary_client.glossaryReadDetailed({"--glossaryGuid": [target_glossary_guid]})
2188
+ existing_term_list = glossary_details.get("terms", [])
2189
+
2190
+ for term in existing_term_list:
2191
+ term_name = term.get("displayText") or term.get("name")
2192
+ term_guid = term.get("termGuid") or term.get("guid")
2193
+ if term_name:
2194
+ existing_terms[term_name.lower()] = term_guid
2195
+
2196
+ console.print(f"[green][OK][/green] Found {len(existing_terms)} existing term(s) in classic glossary\n")
2197
+ except Exception as e:
2198
+ console.print(f"[yellow][!][/yellow] Could not fetch existing terms: {e}\n")
2199
+
2200
+ # Step 4: Sync terms
2201
+ console.print("[bold]Step 4:[/bold] Synchronizing terms...")
2202
+
2203
+ created_count = 0
2204
+ updated_count = 0
2205
+ skipped_count = 0
2206
+ failed_count = 0
2207
+
2208
+ for uc_term in uc_terms:
2209
+ term_name = uc_term.get("name", "")
2210
+ term_description = uc_term.get("description", "")
2211
+ term_status = uc_term.get("status", "Draft")
2212
+
2213
+ # Check if term already exists
2214
+ existing_guid = existing_terms.get(term_name.lower())
2215
+
2216
+ if existing_guid and not update_existing:
2217
+ console.print(f" [dim][-] Skipping:[/dim] {term_name} (already exists)")
2218
+ skipped_count += 1
2219
+ continue
2220
+
2221
+ try:
2222
+ if existing_guid and update_existing:
2223
+ # Update existing term
2224
+ if dry_run:
2225
+ console.print(f" [yellow]Would update:[/yellow] {term_name}")
2226
+ updated_count += 1
2227
+ else:
2228
+ update_payload = {
2229
+ "guid": existing_guid,
2230
+ "name": term_name,
2231
+ "longDescription": term_description,
2232
+ "status": term_status,
2233
+ "anchor": {"glossaryGuid": target_glossary_guid}
2234
+ }
2235
+
2236
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
2237
+ json.dump(update_payload, f)
2238
+ temp_file = f.name
2239
+
2240
+ try:
2241
+ glossary_client.glossaryUpdateTerm({"--payloadFile": temp_file})
2242
+ console.print(f" [green][OK] Updated:[/green] {term_name}")
2243
+ updated_count += 1
2244
+ finally:
2245
+ os.unlink(temp_file)
2246
+ else:
2247
+ # Create new term
2248
+ if dry_run:
2249
+ console.print(f" [yellow]Would create:[/yellow] {term_name}")
2250
+ created_count += 1
2251
+ else:
2252
+ create_payload = {
2253
+ "name": term_name,
2254
+ "longDescription": term_description,
2255
+ "status": term_status,
2256
+ "anchor": {"glossaryGuid": target_glossary_guid}
2257
+ }
2258
+
2259
+ # Add optional fields
2260
+ if uc_term.get("acronyms"):
2261
+ create_payload["abbreviation"] = ", ".join(uc_term["acronyms"])
2262
+
2263
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
2264
+ json.dump(create_payload, f)
2265
+ temp_file = f.name
2266
+
2267
+ try:
2268
+ glossary_client.glossaryCreateTerm({"--payloadFile": temp_file})
2269
+ console.print(f" [green][OK] Created:[/green] {term_name}")
2270
+ created_count += 1
2271
+ finally:
2272
+ os.unlink(temp_file)
2273
+
2274
+ except Exception as e:
2275
+ console.print(f" [red][X] Failed:[/red] {term_name} - {str(e)}")
2276
+ failed_count += 1
2277
+
2278
+ # Summary
2279
+ console.print("\n[cyan]" + "-" * 59 + "[/cyan]")
2280
+ console.print("[bold cyan] Synchronization Summary [/bold cyan]")
2281
+ console.print("[cyan]" + "-" * 59 + "[/cyan]")
2282
+
2283
+ summary_table = Table(show_header=False, box=None)
2284
+ summary_table.add_column("Metric", style="bold")
2285
+ summary_table.add_column("Count", style="cyan")
2286
+
2287
+ summary_table.add_row("Total UC Terms", str(len(uc_terms)))
2288
+ summary_table.add_row("Created", f"[green]{created_count}[/green]")
2289
+ summary_table.add_row("Updated", f"[yellow]{updated_count}[/yellow]")
2290
+ summary_table.add_row("Skipped", f"[dim]{skipped_count}[/dim]")
2291
+ summary_table.add_row("Failed", f"[red]{failed_count}[/red]")
2292
+
2293
+ console.print(summary_table)
2294
+
2295
+ if dry_run:
2296
+ console.print("\n[yellow][TIP] This was a dry run. Use without --dry-run to apply changes.[/yellow]")
2297
+ elif failed_count == 0 and (created_count > 0 or updated_count > 0):
2298
+ console.print("\n[green][OK] Synchronization completed successfully![/green]")
2299
+
2300
+ except Exception as e:
2301
+ console.print(f"\n[red]ERROR:[/red] {str(e)}")
2302
+ import traceback
2303
+ if os.getenv("PURVIEWCLI_DEBUG"):
2304
+ console.print(traceback.format_exc())
2305
+
2306
+
2307
+ # ========================================
2308
+ # OBJECTIVES AND KEY RESULTS (OKRs)
2309
+ # ========================================
2310
+
2311
+
2312
+ @uc.group()
2313
+ def objective():
2314
+ """Manage objectives and key results (OKRs)."""
2315
+ pass
2316
+
2317
+
2318
+ @objective.command()
2319
+ @click.option("--definition", required=True, help="Definition of the objective")
2320
+ @click.option("--domain-id", required=True, help="Governance domain ID")
2321
+ @click.option(
2322
+ "--status",
2323
+ required=False,
2324
+ default="Draft",
2325
+ type=click.Choice(["Draft", "Published", "Archived"]),
2326
+ help="Status of the objective",
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
+ @click.option(
2335
+ "--target-date", required=False, help="Target date (ISO format: 2025-12-30T14:00:00.000Z)"
2336
+ )
2337
+ def create(definition, domain_id, status, owner_id, target_date):
2338
+ """Create a new objective."""
2339
+ try:
2340
+ client = UnifiedCatalogClient()
2341
+
2342
+ args = {
2343
+ "--definition": [definition],
2344
+ "--governance-domain-id": [domain_id],
2345
+ "--status": [status],
2346
+ }
2347
+
2348
+ if owner_id:
2349
+ args["--owner-id"] = list(owner_id)
2350
+ if target_date:
2351
+ args["--target-date"] = [target_date]
2352
+
2353
+ result = client.create_objective(args)
2354
+
2355
+ if not result:
2356
+ console.print("[red]ERROR:[/red] No response received")
2357
+ return
2358
+ if isinstance(result, dict) and "error" in result:
2359
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2360
+ return
2361
+
2362
+ console.print(f"[green] SUCCESS:[/green] Created objective")
2363
+ console.print(json.dumps(result, indent=2))
2364
+
2365
+ except Exception as e:
2366
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2367
+
2368
+
2369
+ @objective.command(name="list")
2370
+ @click.option("--domain-id", required=True, help="Governance domain ID to list objectives from")
2371
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
2372
+ def list_objectives(domain_id, output_json):
2373
+ """List all objectives in a governance domain."""
2374
+ try:
2375
+ client = UnifiedCatalogClient()
2376
+ args = {"--governance-domain-id": [domain_id]}
2377
+ result = client.get_objectives(args)
2378
+
2379
+ if not result:
2380
+ console.print("[yellow]No objectives found.[/yellow]")
2381
+ return
2382
+
2383
+ # Handle response format
2384
+ if isinstance(result, (list, tuple)):
2385
+ objectives = result
2386
+ elif isinstance(result, dict):
2387
+ objectives = result.get("value", [])
2388
+ else:
2389
+ objectives = []
2390
+
2391
+ if not objectives:
2392
+ console.print("[yellow]No objectives found.[/yellow]")
2393
+ return
2394
+
2395
+ # Output in JSON format if requested
2396
+ if output_json:
2397
+ _format_json_output(objectives)
2398
+ return
2399
+
2400
+ table = Table(title="Objectives")
2401
+ table.add_column("ID", style="cyan")
2402
+ table.add_column("Definition", style="green")
2403
+ table.add_column("Status", style="yellow")
2404
+ table.add_column("Target Date", style="blue")
2405
+
2406
+ for obj in objectives:
2407
+ definition = obj.get("definition", "")
2408
+ if len(definition) > 50:
2409
+ definition = definition[:50] + "..."
2410
+
2411
+ table.add_row(
2412
+ obj.get("id", "N/A"),
2413
+ definition,
2414
+ obj.get("status", "N/A"),
2415
+ obj.get("targetDate", "N/A"),
2416
+ )
2417
+
2418
+ console.print(table)
2419
+
2420
+ except Exception as e:
2421
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2422
+
2423
+
2424
+ @objective.command()
2425
+ @click.option("--objective-id", required=True, help="ID of the objective")
2426
+ def show(objective_id):
2427
+ """Show details of an objective."""
2428
+ try:
2429
+ client = UnifiedCatalogClient()
2430
+ args = {"--objective-id": [objective_id]}
2431
+ result = client.get_objective_by_id(args)
2432
+
2433
+ if not result:
2434
+ console.print("[red]ERROR:[/red] No response received")
2435
+ return
2436
+ if isinstance(result, dict) and "error" in result:
2437
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Objective not found')}")
2438
+ return
2439
+
2440
+ console.print(json.dumps(result, indent=2))
2441
+
2442
+ except Exception as e:
2443
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2444
+
2445
+
2446
+ @objective.command(name="query")
2447
+ @click.option("--ids", multiple=True, help="Filter by specific objective IDs (GUIDs)")
2448
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
2449
+ @click.option("--definition", help="Filter by definition text (partial match)")
2450
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
2451
+ @click.option("--status", type=click.Choice(["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"], case_sensitive=False),
2452
+ help="Filter by status")
2453
+ @click.option("--multi-status", multiple=True,
2454
+ type=click.Choice(["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"], case_sensitive=False),
2455
+ help="Filter by multiple statuses")
2456
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
2457
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
2458
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
2459
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
2460
+ help="Sort direction")
2461
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2462
+ def query_objectives(ids, domain_ids, definition, owners, status, multi_status,
2463
+ skip, top, order_by_field, order_by_direction, output):
2464
+ """Query objectives with advanced filters.
2465
+
2466
+ Perform complex searches across OKR objectives using multiple filter criteria.
2467
+ Supports pagination and custom sorting.
2468
+
2469
+ Examples:
2470
+ # Find all objectives in a specific domain
2471
+ pvw uc objective query --domain-ids <domain-guid>
2472
+
2473
+ # Search by definition text
2474
+ pvw uc objective query --definition "customer satisfaction"
2475
+
2476
+ # Filter by owner and status
2477
+ pvw uc objective query --owners <user-guid> --status ACTIVE
2478
+
2479
+ # Find all completed objectives
2480
+ pvw uc objective query --multi-status COMPLETED ARCHIVED
2481
+
2482
+ # Pagination example
2483
+ pvw uc objective query --skip 0 --top 50 --order-by-field name --order-by-direction asc
2484
+ """
2485
+ try:
2486
+ client = UnifiedCatalogClient()
2487
+ args = {}
2488
+
2489
+ # Build args dict from parameters
2490
+ if ids:
2491
+ args["--ids"] = list(ids)
2492
+ if domain_ids:
2493
+ args["--domain-ids"] = list(domain_ids)
2494
+ if definition:
2495
+ args["--definition"] = [definition]
2496
+ if owners:
2497
+ args["--owners"] = list(owners)
2498
+ if status:
2499
+ args["--status"] = [status]
2500
+ if multi_status:
2501
+ args["--multi-status"] = list(multi_status)
2502
+ if skip:
2503
+ args["--skip"] = [str(skip)]
2504
+ if top:
2505
+ args["--top"] = [str(top)]
2506
+ if order_by_field:
2507
+ args["--order-by-field"] = [order_by_field]
2508
+ args["--order-by-direction"] = [order_by_direction]
2509
+
2510
+ result = client.query_objectives(args)
2511
+
2512
+ if output == "json":
2513
+ console.print_json(data=result)
2514
+ else:
2515
+ objectives = result.get("value", []) if result else []
2516
+
2517
+ if not objectives:
2518
+ console.print("[yellow]No objectives found matching the query.[/yellow]")
2519
+ return
2520
+
2521
+ # Check for pagination
2522
+ next_link = result.get("nextLink")
2523
+ if next_link:
2524
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2525
+
2526
+ table = Table(title=f"Query Results ({len(objectives)} found)", show_header=True)
2527
+ table.add_column("Name", style="cyan")
2528
+ table.add_column("ID", style="dim", no_wrap=True)
2529
+ table.add_column("Domain", style="yellow", no_wrap=True)
2530
+ table.add_column("Status", style="white")
2531
+ table.add_column("Owner", style="green", no_wrap=True)
2532
+
2533
+ for obj in objectives:
2534
+ owner_display = "N/A"
2535
+ if obj.get("owner"):
2536
+ owner_display = obj["owner"].get("id", "N/A")[:13] + "..."
2537
+
2538
+ table.add_row(
2539
+ obj.get("name", "N/A"),
2540
+ obj.get("id", "N/A")[:13] + "...",
2541
+ obj.get("domain", "N/A")[:13] + "...",
2542
+ obj.get("status", "N/A"),
2543
+ owner_display
2544
+ )
2545
+
2546
+ console.print(table)
2547
+
2548
+ # Show pagination info
2549
+ if skip > 0 or next_link:
2550
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(objectives)}[/dim]")
2551
+
2552
+ except Exception as e:
2553
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2554
+
2555
+
2556
+ # ========================================
2557
+ # CRITICAL DATA ELEMENTS (CDEs)
2558
+ # ========================================
2559
+
2560
+
2561
+ @uc.group()
2562
+ def cde():
2563
+ """Manage critical data elements."""
2564
+ pass
2565
+
2566
+
2567
+ @cde.command()
2568
+ @click.option("--name", required=True, help="Name of the critical data element")
2569
+ @click.option("--description", required=False, default="", help="Description of the CDE")
2570
+ @click.option("--domain-id", required=True, help="Governance domain ID")
2571
+ @click.option(
2572
+ "--data-type",
2573
+ required=True,
2574
+ type=click.Choice(["String", "Number", "Boolean", "Date", "DateTime"]),
2575
+ help="Data type of the CDE",
2576
+ )
2577
+ @click.option(
2578
+ "--status",
2579
+ required=False,
2580
+ default="Draft",
2581
+ type=click.Choice(["Draft", "Published", "Archived"]),
2582
+ help="Status of the CDE",
2583
+ )
2584
+ @click.option(
2585
+ "--owner-id",
2586
+ required=False,
2587
+ help="Owner Entra ID (can be specified multiple times)",
2588
+ multiple=True,
2589
+ )
2590
+ def create(name, description, domain_id, data_type, status, owner_id):
2591
+ """Create a new critical data element."""
2592
+ try:
2593
+ client = UnifiedCatalogClient()
2594
+
2595
+ args = {
2596
+ "--name": [name],
2597
+ "--description": [description],
2598
+ "--governance-domain-id": [domain_id],
2599
+ "--data-type": [data_type],
2600
+ "--status": [status],
2601
+ }
2602
+
2603
+ if owner_id:
2604
+ args["--owner-id"] = list(owner_id)
2605
+
2606
+ result = client.create_critical_data_element(args)
2607
+
2608
+ if not result:
2609
+ console.print("[red]ERROR:[/red] No response received")
2610
+ return
2611
+ if isinstance(result, dict) and "error" in result:
2612
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2613
+ return
2614
+
2615
+ console.print(f"[green] SUCCESS:[/green] Created critical data element '{name}'")
2616
+ console.print(json.dumps(result, indent=2))
2617
+
2618
+ except Exception as e:
2619
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2620
+
2621
+
2622
+ @cde.command(name="list")
2623
+ @click.option("--domain-id", required=True, help="Governance domain ID to list CDEs from")
2624
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
2625
+ def list_cdes(domain_id, output_json):
2626
+ """List all critical data elements in a governance domain."""
2627
+ try:
2628
+ client = UnifiedCatalogClient()
2629
+ args = {"--governance-domain-id": [domain_id]}
2630
+ result = client.get_critical_data_elements(args)
2631
+
2632
+ if not result:
2633
+ console.print("[yellow]No critical data elements found.[/yellow]")
2634
+ return
2635
+
2636
+ # Handle response format
2637
+ if isinstance(result, (list, tuple)):
2638
+ cdes = result
2639
+ elif isinstance(result, dict):
2640
+ cdes = result.get("value", [])
2641
+ else:
2642
+ cdes = []
2643
+
2644
+ if not cdes:
2645
+ console.print("[yellow]No critical data elements found.[/yellow]")
2646
+ return
2647
+
2648
+ # Output in JSON format if requested
2649
+ if output_json:
2650
+ _format_json_output(cdes)
2651
+ return
2652
+
2653
+ table = Table(title="Critical Data Elements")
2654
+ table.add_column("ID", style="cyan")
2655
+ table.add_column("Name", style="green")
2656
+ table.add_column("Data Type", style="blue")
2657
+ table.add_column("Status", style="yellow")
2658
+ table.add_column("Description", style="white")
2659
+
2660
+ for cde_item in cdes:
2661
+ desc = cde_item.get("description", "")
2662
+ if len(desc) > 30:
2663
+ desc = desc[:30] + "..."
2664
+
2665
+ table.add_row(
2666
+ cde_item.get("id", "N/A"),
2667
+ cde_item.get("name", "N/A"),
2668
+ cde_item.get("dataType", "N/A"),
2669
+ cde_item.get("status", "N/A"),
2670
+ desc,
2671
+ )
2672
+
2673
+ console.print(table)
2674
+
2675
+ except Exception as e:
2676
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2677
+
2678
+
2679
+ @cde.command()
2680
+ @click.option("--cde-id", required=True, help="ID of the critical data element")
2681
+ def show(cde_id):
2682
+ """Show details of a critical data element."""
2683
+ try:
2684
+ client = UnifiedCatalogClient()
2685
+ args = {"--cde-id": [cde_id]}
2686
+ result = client.get_critical_data_element_by_id(args)
2687
+
2688
+ if not result:
2689
+ console.print("[red]ERROR:[/red] No response received")
2690
+ return
2691
+ if isinstance(result, dict) and "error" in result:
2692
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'CDE not found')}")
2693
+ return
2694
+
2695
+ console.print(json.dumps(result, indent=2))
2696
+
2697
+ except Exception as e:
2698
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2699
+
2700
+
2701
+ @cde.command(name="add-relationship")
2702
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2703
+ @click.option("--entity-type", required=True,
2704
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2705
+ help="Type of entity to relate to")
2706
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to relate to")
2707
+ @click.option("--asset-id", help="Asset ID (GUID) - defaults to entity-id if not provided")
2708
+ @click.option("--relationship-type", default="Related", help="Relationship type (default: Related)")
2709
+ @click.option("--description", default="", help="Description of the relationship")
2710
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2711
+ def add_cde_relationship(cde_id, entity_type, entity_id, asset_id, relationship_type, description, output):
2712
+ """Create a relationship for a critical data element.
2713
+
2714
+ Links a CDE to another entity like a critical data column, term, or data product.
2715
+
2716
+ Examples:
2717
+ pvw uc cde add-relationship --cde-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
2718
+ pvw uc cde add-relationship --cde-id <id> --entity-type TERM --entity-id <term-id> --description "Primary term"
2719
+ """
2720
+ try:
2721
+ client = UnifiedCatalogClient()
2722
+ args = {
2723
+ "--cde-id": [cde_id],
2724
+ "--entity-type": [entity_type],
2725
+ "--entity-id": [entity_id],
2726
+ "--relationship-type": [relationship_type],
2727
+ "--description": [description]
2728
+ }
2729
+
2730
+ if asset_id:
2731
+ args["--asset-id"] = [asset_id]
2732
+
2733
+ result = client.create_cde_relationship(args)
2734
+
2735
+ if output == "json":
2736
+ console.print_json(data=result)
2737
+ else:
2738
+ if result and isinstance(result, dict):
2739
+ console.print("[green]SUCCESS:[/green] Created CDE relationship")
2740
+ table = Table(title="CDE Relationship", show_header=True)
2741
+ table.add_column("Property", style="cyan")
2742
+ table.add_column("Value", style="white")
2743
+
2744
+ table.add_row("Entity ID", result.get("entityId", "N/A"))
2745
+ table.add_row("Relationship Type", result.get("relationshipType", "N/A"))
2746
+ table.add_row("Description", result.get("description", "N/A"))
2747
+
2748
+ if "systemData" in result:
2749
+ sys_data = result["systemData"]
2750
+ table.add_row("Created By", sys_data.get("createdBy", "N/A"))
2751
+ table.add_row("Created At", sys_data.get("createdAt", "N/A"))
2752
+
2753
+ console.print(table)
2754
+ else:
2755
+ console.print("[green]SUCCESS:[/green] Created CDE relationship")
2756
+
2757
+ except Exception as e:
2758
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2759
+
2760
+
2761
+ @cde.command(name="list-relationships")
2762
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2763
+ @click.option("--entity-type",
2764
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2765
+ help="Filter by entity type (optional)")
2766
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2767
+ def list_cde_relationships(cde_id, entity_type, output):
2768
+ """List relationships for a critical data element.
2769
+
2770
+ Shows all entities linked to this CDE, optionally filtered by type.
2771
+
2772
+ Examples:
2773
+ pvw uc cde list-relationships --cde-id <id>
2774
+ pvw uc cde list-relationships --cde-id <id> --entity-type CRITICALDATACOLUMN
2775
+ """
2776
+ try:
2777
+ client = UnifiedCatalogClient()
2778
+ args = {"--cde-id": [cde_id]}
2779
+
2780
+ if entity_type:
2781
+ args["--entity-type"] = [entity_type]
2782
+
2783
+ result = client.get_cde_relationships(args)
2784
+
2785
+ if output == "json":
2786
+ console.print_json(data=result)
2787
+ else:
2788
+ relationships = result.get("value", []) if result else []
2789
+
2790
+ if not relationships:
2791
+ console.print(f"[yellow]No relationships found for CDE '{cde_id}'[/yellow]")
2792
+ return
2793
+
2794
+ table = Table(title=f"CDE Relationships ({len(relationships)} found)", show_header=True)
2795
+ table.add_column("Entity ID", style="cyan")
2796
+ table.add_column("Relationship Type", style="white")
2797
+ table.add_column("Description", style="white")
2798
+ table.add_column("Created", style="dim")
2799
+
2800
+ for rel in relationships:
2801
+ table.add_row(
2802
+ rel.get("entityId", "N/A"),
2803
+ rel.get("relationshipType", "N/A"),
2804
+ rel.get("description", "")[:50] + ("..." if len(rel.get("description", "")) > 50 else ""),
2805
+ rel.get("systemData", {}).get("createdAt", "N/A")[:10]
2806
+ )
2807
+
2808
+ console.print(table)
2809
+
2810
+ except Exception as e:
2811
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2812
+
2813
+
2814
+ @cde.command(name="remove-relationship")
2815
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2816
+ @click.option("--entity-type", required=True,
2817
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2818
+ help="Type of entity to unlink")
2819
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to unlink")
2820
+ @click.option("--confirm/--no-confirm", default=True, help="Ask for confirmation before deleting")
2821
+ def remove_cde_relationship(cde_id, entity_type, entity_id, confirm):
2822
+ """Delete a relationship between a CDE and an entity.
2823
+
2824
+ Removes the link between a critical data element and a specific entity.
2825
+
2826
+ Examples:
2827
+ pvw uc cde remove-relationship --cde-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
2828
+ pvw uc cde remove-relationship --cde-id <id> --entity-type TERM --entity-id <term-id> --no-confirm
2829
+ """
2830
+ try:
2831
+ if confirm:
2832
+ confirm = click.confirm(
2833
+ f"Are you sure you want to delete CDE relationship to {entity_type} '{entity_id}'?",
2834
+ default=False
2835
+ )
2836
+ if not confirm:
2837
+ console.print("[yellow]Deletion cancelled.[/yellow]")
2838
+ return
2839
+
2840
+ client = UnifiedCatalogClient()
2841
+ args = {
2842
+ "--cde-id": [cde_id],
2843
+ "--entity-type": [entity_type],
2844
+ "--entity-id": [entity_id]
2845
+ }
2846
+
2847
+ result = client.delete_cde_relationship(args)
2848
+
2849
+ # DELETE returns 204 No Content on success
2850
+ if result is None or (isinstance(result, dict) and not result.get("error")):
2851
+ console.print(f"[green]SUCCESS:[/green] Deleted CDE relationship to {entity_type} '{entity_id}'")
2852
+ elif isinstance(result, dict) and "error" in result:
2853
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2854
+ else:
2855
+ console.print(f"[green]SUCCESS:[/green] Deleted CDE relationship")
2856
+
2857
+ except Exception as e:
2858
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2859
+
2860
+
2861
+ @cde.command(name="query")
2862
+ @click.option("--ids", multiple=True, help="Filter by specific CDE IDs (GUIDs)")
2863
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
2864
+ @click.option("--name-keyword", help="Filter by name keyword (partial match)")
2865
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
2866
+ @click.option("--status", type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
2867
+ help="Filter by status")
2868
+ @click.option("--multi-status", multiple=True,
2869
+ type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
2870
+ help="Filter by multiple statuses")
2871
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
2872
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
2873
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
2874
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
2875
+ help="Sort direction")
2876
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2877
+ def query_cdes(ids, domain_ids, name_keyword, owners, status, multi_status,
2878
+ skip, top, order_by_field, order_by_direction, output):
2879
+ """Query critical data elements with advanced filters.
2880
+
2881
+ Perform complex searches across CDEs using multiple filter criteria.
2882
+ Supports pagination and custom sorting.
2883
+
2884
+ Examples:
2885
+ # Find all CDEs in a specific domain
2886
+ pvw uc cde query --domain-ids <domain-guid>
2887
+
2888
+ # Search by keyword
2889
+ pvw uc cde query --name-keyword "customer"
2890
+
2891
+ # Filter by owner and status
2892
+ pvw uc cde query --owners <user-guid> --status PUBLISHED
2893
+
2894
+ # Find all published or expired CDEs
2895
+ pvw uc cde query --multi-status PUBLISHED EXPIRED
2896
+
2897
+ # Pagination example
2898
+ pvw uc cde query --skip 0 --top 50 --order-by-field name --order-by-direction desc
2899
+ """
2900
+ try:
2901
+ client = UnifiedCatalogClient()
2902
+ args = {}
2903
+
2904
+ # Build args dict from parameters
2905
+ if ids:
2906
+ args["--ids"] = list(ids)
2907
+ if domain_ids:
2908
+ args["--domain-ids"] = list(domain_ids)
2909
+ if name_keyword:
2910
+ args["--name-keyword"] = [name_keyword]
2911
+ if owners:
2912
+ args["--owners"] = list(owners)
2913
+ if status:
2914
+ args["--status"] = [status]
2915
+ if multi_status:
2916
+ args["--multi-status"] = list(multi_status)
2917
+ if skip:
2918
+ args["--skip"] = [str(skip)]
2919
+ if top:
2920
+ args["--top"] = [str(top)]
2921
+ if order_by_field:
2922
+ args["--order-by-field"] = [order_by_field]
2923
+ args["--order-by-direction"] = [order_by_direction]
2924
+
2925
+ result = client.query_critical_data_elements(args)
2926
+
2927
+ if output == "json":
2928
+ console.print_json(data=result)
2929
+ else:
2930
+ cdes = result.get("value", []) if result else []
2931
+
2932
+ if not cdes:
2933
+ console.print("[yellow]No critical data elements found matching the query.[/yellow]")
2934
+ return
2935
+
2936
+ # Check for pagination
2937
+ next_link = result.get("nextLink")
2938
+ if next_link:
2939
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2940
+
2941
+ table = Table(title=f"Query Results ({len(cdes)} found)", show_header=True)
2942
+ table.add_column("Name", style="cyan")
2943
+ table.add_column("ID", style="dim", no_wrap=True)
2944
+ table.add_column("Domain", style="yellow", no_wrap=True)
2945
+ table.add_column("Status", style="white")
2946
+ table.add_column("Data Type", style="green")
2947
+
2948
+ for cde in cdes:
2949
+ table.add_row(
2950
+ cde.get("name", "N/A"),
2951
+ cde.get("id", "N/A")[:13] + "...",
2952
+ cde.get("domain", "N/A")[:13] + "...",
2953
+ cde.get("status", "N/A"),
2954
+ cde.get("dataType", "N/A")
2955
+ )
2956
+
2957
+ console.print(table)
2958
+
2959
+ # Show pagination info
2960
+ if skip > 0 or next_link:
2961
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(cdes)}[/dim]")
2962
+
2963
+ except Exception as e:
2964
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2965
+
2966
+
2967
+ # ========================================
2968
+ # KEY RESULTS (OKRs)
2969
+ # ========================================
2970
+
2971
+
2972
+ @uc.group()
2973
+ def keyresult():
2974
+ """Manage key results for objectives (OKRs)."""
2975
+ pass
2976
+
2977
+
2978
+ @keyresult.command(name="list")
2979
+ @click.option("--objective-id", required=True, help="Objective ID to list key results for")
2980
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
2981
+ def list_key_results(objective_id, output_json):
2982
+ """List all key results for an objective."""
2983
+ try:
2984
+ client = UnifiedCatalogClient()
2985
+ args = {"--objective-id": [objective_id]}
2986
+ result = client.get_key_results(args)
2987
+
2988
+ if not result:
2989
+ console.print("[yellow]No key results found.[/yellow]")
2990
+ return
2991
+
2992
+ # Handle response format
2993
+ if isinstance(result, (list, tuple)):
2994
+ key_results = result
2995
+ elif isinstance(result, dict):
2996
+ key_results = result.get("value", [])
2997
+ else:
2998
+ key_results = []
2999
+
3000
+ if not key_results:
3001
+ console.print("[yellow]No key results found.[/yellow]")
3002
+ return
3003
+
3004
+ # Output in JSON format if requested
3005
+ if output_json:
3006
+ _format_json_output(key_results)
3007
+ return
3008
+
3009
+ table = Table(title=f"Key Results for Objective {objective_id[:8]}...")
3010
+ table.add_column("ID", style="cyan", no_wrap=True)
3011
+ table.add_column("Definition", style="green", max_width=50)
3012
+ table.add_column("Progress", style="blue")
3013
+ table.add_column("Goal", style="yellow")
3014
+ table.add_column("Max", style="magenta")
3015
+ table.add_column("Status", style="white")
3016
+
3017
+ for kr in key_results:
3018
+ definition = kr.get("definition", "N/A")
3019
+ if len(definition) > 47:
3020
+ definition = definition[:47] + "..."
3021
+
3022
+ table.add_row(
3023
+ kr.get("id", "N/A")[:13] + "...",
3024
+ definition,
3025
+ str(kr.get("progress", "N/A")),
3026
+ str(kr.get("goal", "N/A")),
3027
+ str(kr.get("max", "N/A")),
3028
+ kr.get("status", "N/A"),
3029
+ )
3030
+
3031
+ console.print(table)
3032
+ console.print(f"\n[dim]Found {len(key_results)} key result(s)[/dim]")
3033
+
3034
+ except Exception as e:
3035
+ console.print(f"[red]ERROR:[/red] {str(e)}")
3036
+
3037
+
3038
+ @keyresult.command()
3039
+ @click.option("--objective-id", required=True, help="Objective ID")
3040
+ @click.option("--key-result-id", required=True, help="Key result ID")
3041
+ def show(objective_id, key_result_id):
3042
+ """Show details of a key result."""
3043
+ try:
3044
+ client = UnifiedCatalogClient()
3045
+ args = {
3046
+ "--objective-id": [objective_id],
3047
+ "--key-result-id": [key_result_id]
3048
+ }
3049
+ result = client.get_key_result_by_id(args)
3050
+
3051
+ if not result:
3052
+ console.print("[red]ERROR:[/red] No response received")
3053
+ return
3054
+ if isinstance(result, dict) and "error" in result:
3055
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Key result not found')}")
3056
+ return
3057
+
3058
+ console.print(json.dumps(result, indent=2))
3059
+
3060
+ except Exception as e:
3061
+ console.print(f"[red]ERROR:[/red] {str(e)}")
3062
+
3063
+
3064
+ @keyresult.command()
3065
+ @click.option("--objective-id", required=True, help="Objective ID")
3066
+ @click.option("--governance-domain-id", required=True, help="Governance domain ID")
3067
+ @click.option("--definition", required=True, help="Definition/description of the key result")
3068
+ @click.option("--progress", required=False, type=int, default=0, help="Current progress value (default: 0)")
3069
+ @click.option("--goal", required=True, type=int, help="Target goal value")
3070
+ @click.option("--max", "max_value", required=False, type=int, default=100, help="Maximum possible value (default: 100)")
3071
+ @click.option(
3072
+ "--status",
3073
+ required=False,
3074
+ default="OnTrack",
3075
+ type=click.Choice(["OnTrack", "AtRisk", "OffTrack", "Completed"]),
3076
+ help="Status of the key result",
3077
+ )
3078
+ def create(objective_id, governance_domain_id, definition, progress, goal, max_value, status):
3079
+ """Create a new key result for an objective."""
3080
+ try:
3081
+ client = UnifiedCatalogClient()
3082
+
3083
+ args = {
3084
+ "--objective-id": [objective_id],
3085
+ "--governance-domain-id": [governance_domain_id],
3086
+ "--definition": [definition],
3087
+ "--progress": [str(progress)],
3088
+ "--goal": [str(goal)],
3089
+ "--max": [str(max_value)],
3090
+ "--status": [status],
3091
+ }
3092
+
3093
+ result = client.create_key_result(args)
3094
+
3095
+ if not result:
3096
+ console.print("[red]ERROR:[/red] No response received")
3097
+ return
3098
+ if isinstance(result, dict) and "error" in result:
3099
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
3100
+ return
3101
+
3102
+ console.print(f"[green]SUCCESS:[/green] Created key result")
3103
+ console.print(json.dumps(result, indent=2))
3104
+
3105
+ except Exception as e:
3106
+ console.print(f"[red]ERROR:[/red] {str(e)}")
3107
+
3108
+
3109
+ @keyresult.command()
3110
+ @click.option("--objective-id", required=True, help="Objective ID")
3111
+ @click.option("--key-result-id", required=True, help="Key result ID to update")
3112
+ @click.option("--governance-domain-id", required=False, help="Governance domain ID")
3113
+ @click.option("--definition", required=False, help="New definition/description")
3114
+ @click.option("--progress", required=False, type=int, help="New progress value")
3115
+ @click.option("--goal", required=False, type=int, help="New goal value")
3116
+ @click.option("--max", "max_value", required=False, type=int, help="New maximum value")
3117
+ @click.option(
3118
+ "--status",
3119
+ required=False,
3120
+ type=click.Choice(["OnTrack", "AtRisk", "OffTrack", "Completed"]),
3121
+ help="Status of the key result",
3122
+ )
3123
+ def update(objective_id, key_result_id, governance_domain_id, definition, progress, goal, max_value, status):
3124
+ """Update an existing key result."""
3125
+ try:
3126
+ client = UnifiedCatalogClient()
3127
+
3128
+ # Build args dictionary - only include provided values
3129
+ args = {
3130
+ "--objective-id": [objective_id],
3131
+ "--key-result-id": [key_result_id]
3132
+ }
3133
+
3134
+ if governance_domain_id:
3135
+ args["--governance-domain-id"] = [governance_domain_id]
3136
+ if definition:
3137
+ args["--definition"] = [definition]
3138
+ if progress is not None:
3139
+ args["--progress"] = [str(progress)]
3140
+ if goal is not None:
3141
+ args["--goal"] = [str(goal)]
3142
+ if max_value is not None:
3143
+ args["--max"] = [str(max_value)]
3144
+ if status:
3145
+ args["--status"] = [status]
3146
+
3147
+ result = client.update_key_result(args)
3148
+
3149
+ if not result:
3150
+ console.print("[red]ERROR:[/red] No response received")
3151
+ return
3152
+ if isinstance(result, dict) and "error" in result:
3153
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
3154
+ return
3155
+
3156
+ console.print(f"[green]SUCCESS:[/green] Updated key result '{key_result_id}'")
3157
+ console.print(json.dumps(result, indent=2))
3158
+
3159
+ except Exception as e:
3160
+ console.print(f"[red]ERROR:[/red] {str(e)}")
3161
+
3162
+
3163
+ @keyresult.command()
3164
+ @click.option("--objective-id", required=True, help="Objective ID")
3165
+ @click.option("--key-result-id", required=True, help="Key result ID to delete")
3166
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
3167
+ def delete(objective_id, key_result_id, yes):
3168
+ """Delete a key result."""
3169
+ try:
3170
+ if not yes:
3171
+ confirm = click.confirm(
3172
+ f"Are you sure you want to delete key result '{key_result_id}'?",
3173
+ default=False
3174
+ )
3175
+ if not confirm:
3176
+ console.print("[yellow]Deletion cancelled.[/yellow]")
3177
+ return
3178
+
3179
+ client = UnifiedCatalogClient()
3180
+ args = {
3181
+ "--objective-id": [objective_id],
3182
+ "--key-result-id": [key_result_id]
3183
+ }
3184
+ result = client.delete_key_result(args)
3185
+
3186
+ # DELETE operations may return empty response on success
3187
+ if result is None or (isinstance(result, dict) and not result.get("error")):
3188
+ console.print(f"[green]SUCCESS:[/green] Deleted key result '{key_result_id}'")
3189
+ elif isinstance(result, dict) and "error" in result:
3190
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
3191
+ else:
3192
+ console.print(f"[green]SUCCESS:[/green] Deleted key result")
3193
+ if result:
3194
+ console.print(json.dumps(result, indent=2))
3195
+
3196
+ except Exception as e:
3197
+ console.print(f"[red]ERROR:[/red] {str(e)}")
3198
+
3199
+
3200
+ # ========================================
3201
+ # HEALTH MANAGEMENT - IMPLEMENTED!
3202
+ # ========================================
3203
+
3204
+ # Import and register health commands from dedicated module
3205
+ from purviewcli.cli.health import health as health_commands
3206
+ uc.add_command(health_commands, name="health")
3207
+
3208
+
3209
+ # ========================================
3210
+ # DATA POLICIES (NEW)
3211
+ # ========================================
3212
+
3213
+
3214
+ @uc.group()
3215
+ def policy():
3216
+ """Manage data governance policies."""
3217
+ pass
3218
+
3219
+
3220
+ @policy.command(name="list")
3221
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
3222
+ def list_policies(output):
3223
+ """List all data governance policies."""
3224
+ client = UnifiedCatalogClient()
3225
+ response = client.list_policies({})
3226
+
3227
+ if output == "json":
3228
+ console.print_json(json.dumps(response))
3229
+ else:
3230
+ # API returns 'values' (plural), not 'value'
3231
+ policies = response.get("values", response.get("value", []))
3232
+
3233
+ if policies:
3234
+ table = Table(title="[bold cyan]Data Governance Policies[/bold cyan]", show_header=True)
3235
+ table.add_column("ID", style="cyan", no_wrap=True)
3236
+ table.add_column("Name", style="green")
3237
+ table.add_column("Entity Type", style="yellow")
3238
+ table.add_column("Entity ID", style="magenta", no_wrap=True)
3239
+ table.add_column("Rules", style="white")
3240
+
3241
+ for item in policies:
3242
+ props = item.get("properties", {})
3243
+ entity = props.get("entity", {})
3244
+ entity_type = entity.get("type", "N/A")
3245
+ entity_ref = entity.get("referenceName", "N/A")
3246
+
3247
+ # Count rules
3248
+ decision_rules = len(props.get("decisionRules", []))
3249
+ attribute_rules = len(props.get("attributeRules", []))
3250
+ rules_summary = f"{decision_rules} decision, {attribute_rules} attribute"
3251
+
3252
+ table.add_row(
3253
+ item.get("id", "N/A")[:36], # Show only GUID
3254
+ item.get("name", "N/A"),
3255
+ entity_type.replace("Reference", ""), # Clean up type name
3256
+ entity_ref[:36], # Show only GUID
3257
+ rules_summary
3258
+ )
3259
+
3260
+ console.print(table)
3261
+ console.print(f"\n[dim]Total: {len(policies)} policy/policies[/dim]")
3262
+ else:
3263
+ console.print("[yellow]No policies found[/yellow]")
3264
+
3265
+
3266
+ @policy.command(name="get")
3267
+ @click.option("--policy-id", required=True, help="Policy ID")
3268
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3269
+ def get_policy(policy_id, output):
3270
+ """Get a specific data governance policy by ID."""
3271
+ client = UnifiedCatalogClient()
3272
+
3273
+ # Get all policies and filter (since GET by ID returns 404)
3274
+ all_policies = client.list_policies({})
3275
+ policies = all_policies.get("values", all_policies.get("value", []))
3276
+
3277
+ # Find the requested policy
3278
+ policy = next((p for p in policies if p.get("id") == policy_id), None)
3279
+
3280
+ if not policy:
3281
+ console.print(f"[red]ERROR:[/red] Policy with ID {policy_id} not found")
3282
+ return
3283
+
3284
+ if output == "json":
3285
+ _format_json_output(policy)
3286
+ else:
3287
+ # Display policy in formatted view
3288
+ props = policy.get("properties", {})
3289
+ entity = props.get("entity", {})
3290
+
3291
+ console.print(f"\n[bold cyan]Policy Details[/bold cyan]")
3292
+ console.print(f"[bold]ID:[/bold] {policy.get('id')}")
3293
+ console.print(f"[bold]Name:[/bold] {policy.get('name')}")
3294
+ console.print(f"[bold]Version:[/bold] {policy.get('version', 0)}")
3295
+
3296
+ console.print(f"\n[bold cyan]Entity[/bold cyan]")
3297
+ console.print(f"[bold]Type:[/bold] {entity.get('type', 'N/A')}")
3298
+ console.print(f"[bold]Reference:[/bold] {entity.get('referenceName', 'N/A')}")
3299
+ console.print(f"[bold]Parent:[/bold] {props.get('parentEntityName', 'N/A')}")
3300
+
3301
+ # Decision Rules
3302
+ decision_rules = props.get("decisionRules", [])
3303
+ if decision_rules:
3304
+ console.print(f"\n[bold cyan]Decision Rules ({len(decision_rules)})[/bold cyan]")
3305
+ for i, rule in enumerate(decision_rules, 1):
3306
+ console.print(f"\n [bold]Rule {i}:[/bold] {rule.get('kind', 'N/A')}")
3307
+ console.print(f" [bold]Effect:[/bold] {rule.get('effect', 'N/A')}")
3308
+ if "dnfCondition" in rule:
3309
+ console.print(f" [bold]Conditions:[/bold] {len(rule['dnfCondition'])} clause(s)")
3310
+
3311
+ # Attribute Rules
3312
+ attribute_rules = props.get("attributeRules", [])
3313
+ if attribute_rules:
3314
+ console.print(f"\n[bold cyan]Attribute Rules ({len(attribute_rules)})[/bold cyan]")
3315
+ for i, rule in enumerate(attribute_rules, 1):
3316
+ console.print(f"\n [bold]Rule {i}:[/bold] {rule.get('name', rule.get('id', 'N/A'))}")
3317
+ if "dnfCondition" in rule:
3318
+ conditions = rule.get("dnfCondition", [])
3319
+ console.print(f" [bold]Conditions:[/bold] {len(conditions)} clause(s)")
3320
+ for j, clause in enumerate(conditions[:3], 1): # Show first 3
3321
+ if clause:
3322
+ attr = clause[0] if isinstance(clause, list) else clause
3323
+ console.print(f" {j}. {attr.get('attributeName', 'N/A')}")
3324
+ if len(conditions) > 3:
3325
+ console.print(f" ... and {len(conditions) - 3} more")
3326
+
3327
+ console.print()
3328
+
3329
+
3330
+
3331
+ @policy.command(name="create")
3332
+ @click.option("--name", required=True, help="Policy name")
3333
+ @click.option("--policy-type", required=True, help="Policy type (e.g., access, retention)")
3334
+ @click.option("--description", default="", help="Policy description")
3335
+ @click.option("--status", default="active", help="Policy status (active, draft)")
3336
+ def create_policy(name, policy_type, description, status):
3337
+ """Create a new data governance policy."""
3338
+ client = UnifiedCatalogClient()
3339
+ args = {
3340
+ "--name": [name],
3341
+ "--policy-type": [policy_type],
3342
+ "--description": [description],
3343
+ "--status": [status]
3344
+ }
3345
+ response = client.create_policy(args)
3346
+
3347
+ console.print(f"[green]SUCCESS:[/green] Policy created")
3348
+ _format_json_output(response)
3349
+
3350
+
3351
+ @policy.command(name="update")
3352
+ @click.option("--policy-id", required=True, help="Policy ID")
3353
+ @click.option("--name", help="New policy name")
3354
+ @click.option("--description", help="New policy description")
3355
+ @click.option("--status", help="New policy status")
3356
+ def update_policy(policy_id, name, description, status):
3357
+ """Update an existing data governance policy."""
3358
+ client = UnifiedCatalogClient()
3359
+ args = {"--policy-id": [policy_id]}
3360
+
3361
+ if name:
3362
+ args["--name"] = [name]
3363
+ if description:
3364
+ args["--description"] = [description]
3365
+ if status:
3366
+ args["--status"] = [status]
3367
+
3368
+ response = client.update_policy(args)
3369
+
3370
+ console.print(f"[green]SUCCESS:[/green] Policy updated")
3371
+ _format_json_output(response)
3372
+
3373
+
3374
+ @policy.command(name="delete")
3375
+ @click.option("--policy-id", required=True, help="Policy ID")
3376
+ @click.confirmation_option(prompt="Are you sure you want to delete this policy?")
3377
+ def delete_policy(policy_id):
3378
+ """Delete a data governance policy."""
3379
+ client = UnifiedCatalogClient()
3380
+ args = {"--policy-id": [policy_id]}
3381
+ response = client.delete_policy(args)
3382
+
3383
+ console.print(f"[green]SUCCESS:[/green] Policy '{policy_id}' deleted")
3384
+
3385
+
3386
+ # ========================================
3387
+ # CUSTOM METADATA (NEW)
3388
+ # ========================================
3389
+
3390
+
3391
+ @uc.group()
3392
+ def metadata():
3393
+ """Manage custom metadata for assets."""
3394
+ pass
3395
+
3396
+
3397
+ @metadata.command(name="list")
3398
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
3399
+ @click.option("--fallback/--no-fallback", default=True, help="Fallback to Business Metadata if UC is empty")
3400
+ def list_custom_metadata(output, fallback):
3401
+ """List all custom metadata definitions.
3402
+
3403
+ Uses Atlas API to get Business Metadata definitions.
3404
+ With fallback enabled, shows user-friendly table format.
3405
+ """
3406
+ client = UnifiedCatalogClient()
3407
+ response = client.list_custom_metadata({})
3408
+
3409
+ # Check if UC API returned business metadata (Atlas returns businessMetadataDefs)
3410
+ has_uc_data = (response and "businessMetadataDefs" in response
3411
+ and response["businessMetadataDefs"])
3412
+
3413
+ if output == "json":
3414
+ if has_uc_data:
3415
+ console.print_json(json.dumps(response))
3416
+ elif fallback:
3417
+ # Fallback message (though Atlas API should always return something)
3418
+ console.print("[dim]No business metadata found.[/dim]\n")
3419
+ console.print_json(json.dumps({"businessMetadataDefs": []}))
3420
+ else:
3421
+ console.print_json(json.dumps(response))
3422
+ else:
3423
+ # Table output
3424
+ if has_uc_data:
3425
+ biz_metadata = response.get('businessMetadataDefs', [])
3426
+
3427
+ if biz_metadata:
3428
+ table = Table(title="[bold green]Business Metadata Attributes[/bold green]", show_header=True)
3429
+ table.add_column("Attribute Name", style="green", no_wrap=True)
3430
+ table.add_column("Group", style="cyan")
3431
+ table.add_column("Type", style="yellow")
3432
+ table.add_column("Scope", style="magenta", max_width=25)
3433
+ table.add_column("Description", style="white", max_width=30)
3434
+
3435
+ total_attrs = 0
3436
+ for group in biz_metadata:
3437
+ group_name = group.get('name', 'N/A')
3438
+ attributes = group.get('attributeDefs', [])
3439
+
3440
+ # Parse group-level scope
3441
+ group_scope = "N/A"
3442
+ options = group.get('options', {})
3443
+ if 'dataGovernanceOptions' in options:
3444
+ try:
3445
+ dg_opts_str = options.get('dataGovernanceOptions', '{}')
3446
+ dg_opts = json.loads(dg_opts_str) if isinstance(dg_opts_str, str) else dg_opts_str
3447
+ applicable = dg_opts.get('applicableConstructs', [])
3448
+ if applicable:
3449
+ # Categorize scope
3450
+ has_business_concept = any('businessConcept' in c or 'domain' in c for c in applicable)
3451
+ has_dataset = any('dataset' in c.lower() for c in applicable)
3452
+
3453
+ if has_business_concept and has_dataset:
3454
+ group_scope = "Universal (Concept + Dataset)"
3455
+ elif has_business_concept:
3456
+ group_scope = "Business Concept"
3457
+ elif has_dataset:
3458
+ group_scope = "Data Asset"
3459
+ else:
3460
+ # Show first 2 constructs
3461
+ scope_parts = []
3462
+ for construct in applicable[:2]:
3463
+ if ':' in construct:
3464
+ scope_parts.append(construct.split(':')[0])
3465
+ else:
3466
+ scope_parts.append(construct)
3467
+ group_scope = ', '.join(scope_parts)
3468
+ except:
3469
+ pass
3470
+
3471
+ for attr in attributes:
3472
+ total_attrs += 1
3473
+ attr_name = attr.get('name', 'N/A')
3474
+ attr_type = attr.get('typeName', 'N/A')
3475
+
3476
+ # Simplify enum types
3477
+ if 'ATTRIBUTE_ENUM_' in attr_type:
3478
+ attr_type = 'Enum'
3479
+
3480
+ attr_desc = attr.get('description', '')
3481
+
3482
+ # Check if attribute has custom scope
3483
+ attr_scope = group_scope
3484
+ attr_opts = attr.get('options', {})
3485
+
3486
+ # Check dataGovernanceOptions first
3487
+ if 'dataGovernanceOptions' in attr_opts:
3488
+ try:
3489
+ attr_dg_str = attr_opts.get('dataGovernanceOptions', '{}')
3490
+ attr_dg = json.loads(attr_dg_str) if isinstance(attr_dg_str, str) else attr_dg_str
3491
+ inherit = attr_dg.get('inheritApplicableConstructsFromGroup', True)
3492
+ if not inherit:
3493
+ attr_applicable = attr_dg.get('applicableConstructs', [])
3494
+ if attr_applicable:
3495
+ # Categorize custom scope
3496
+ has_business_concept = any('businessConcept' in c or 'domain' in c for c in attr_applicable)
3497
+ has_dataset = any('dataset' in c.lower() for c in attr_applicable)
3498
+
3499
+ if has_business_concept and has_dataset:
3500
+ attr_scope = "Universal"
3501
+ elif has_business_concept:
3502
+ attr_scope = "Business Concept"
3503
+ elif has_dataset:
3504
+ attr_scope = "Data Asset"
3505
+ else:
3506
+ attr_scope = f"Custom ({len(attr_applicable)})"
3507
+ except:
3508
+ pass
3509
+
3510
+ # Fallback: Check applicableEntityTypes (older format)
3511
+ if attr_scope == "N/A" and 'applicableEntityTypes' in attr_opts:
3512
+ try:
3513
+ entity_types_str = attr_opts.get('applicableEntityTypes', '[]')
3514
+ # Parse if string, otherwise use as-is
3515
+ if isinstance(entity_types_str, str):
3516
+ entity_types = json.loads(entity_types_str)
3517
+ else:
3518
+ entity_types = entity_types_str
3519
+
3520
+ if entity_types and isinstance(entity_types, list):
3521
+ # Check if entity types are data assets (tables, etc.)
3522
+ if any('table' in et.lower() or 'database' in et.lower() or 'file' in et.lower()
3523
+ for et in entity_types):
3524
+ attr_scope = "Data Asset"
3525
+ else:
3526
+ attr_scope = f"Assets ({len(entity_types)} types)"
3527
+ except Exception as e:
3528
+ # Silently fail but could log for debugging
3529
+ pass
3530
+
3531
+ table.add_row(
3532
+ attr_name,
3533
+ group_name,
3534
+ attr_type,
3535
+ attr_scope,
3536
+ attr_desc[:30] + "..." if len(attr_desc) > 30 else attr_desc
3537
+ )
3538
+
3539
+ console.print(table)
3540
+ console.print(f"\n[cyan]Total:[/cyan] {total_attrs} business metadata attribute(s) in {len(biz_metadata)} group(s)")
3541
+ console.print("\n[dim]Legend:[/dim]")
3542
+ console.print(" [magenta]Business Concept[/magenta] = Applies to Terms, Domains, Business Rules")
3543
+ console.print(" [magenta]Data Asset[/magenta] = Applies to Tables, Files, Databases")
3544
+ console.print(" [magenta]Universal[/magenta] = Applies to both Concepts and Assets")
3545
+ else:
3546
+ console.print("[yellow]No business metadata found[/yellow]")
3547
+ else:
3548
+ console.print("[yellow]No business metadata found[/yellow]")
3549
+
3550
+
3551
+ @metadata.command(name="get")
3552
+ @click.option("--asset-id", required=True, help="Asset GUID")
3553
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3554
+ def get_custom_metadata(asset_id, output):
3555
+ """Get custom metadata (business metadata) for a specific asset."""
3556
+ client = UnifiedCatalogClient()
3557
+ args = {"--asset-id": [asset_id]}
3558
+ response = client.get_custom_metadata(args)
3559
+
3560
+ if output == "json":
3561
+ # Extract businessAttributes from entity response
3562
+ # Note: API returns "businessAttributes" not "businessMetadata"
3563
+ if response and "entity" in response:
3564
+ business_metadata = response["entity"].get("businessAttributes", {})
3565
+ _format_json_output(business_metadata)
3566
+ elif response and isinstance(response, dict):
3567
+ business_metadata = response.get("businessAttributes", {})
3568
+ _format_json_output(business_metadata)
3569
+ else:
3570
+ _format_json_output({})
3571
+ else:
3572
+ table = Table(title=f"[bold cyan]Business Metadata for Asset: {asset_id}[/bold cyan]")
3573
+ table.add_column("Group", style="cyan")
3574
+ table.add_column("Attribute", style="green")
3575
+ table.add_column("Value", style="white")
3576
+
3577
+ if response and "entity" in response:
3578
+ business_metadata = response["entity"].get("businessAttributes", {})
3579
+ if business_metadata:
3580
+ for group_name, attributes in business_metadata.items():
3581
+ if isinstance(attributes, dict):
3582
+ for attr_name, attr_value in attributes.items():
3583
+ table.add_row(group_name, attr_name, str(attr_value))
3584
+ elif response and isinstance(response, dict):
3585
+ business_metadata = response.get("businessAttributes", {})
3586
+ if business_metadata:
3587
+ for group_name, attributes in business_metadata.items():
3588
+ if isinstance(attributes, dict):
3589
+ for attr_name, attr_value in attributes.items():
3590
+ table.add_row(group_name, attr_name, str(attr_value))
3591
+
3592
+ console.print(table)
3593
+
3594
+
3595
+ @metadata.command(name="add")
3596
+ @click.option("--asset-id", required=True, help="Asset GUID")
3597
+ @click.option("--group", required=True, help="Business metadata group name (e.g., 'Governance', 'Privacy')")
3598
+ @click.option("--key", required=True, help="Attribute name")
3599
+ @click.option("--value", required=True, help="Attribute value")
3600
+ def add_custom_metadata(asset_id, group, key, value):
3601
+ """Add custom metadata (business metadata) to an asset.
3602
+
3603
+ Example: pvw uc metadata add --asset-id <guid> --group Governance --key DataOwner --value "John Doe"
3604
+ """
3605
+ client = UnifiedCatalogClient()
3606
+ args = {
3607
+ "--asset-id": [asset_id],
3608
+ "--group": [group],
3609
+ "--key": [key],
3610
+ "--value": [value]
3611
+ }
3612
+ response = client.add_custom_metadata(args)
3613
+
3614
+ console.print(f"[green]SUCCESS:[/green] Business metadata '{key}' added to group '{group}' on asset '{asset_id}'")
3615
+ if response:
3616
+ _format_json_output(response)
3617
+
3618
+
3619
+ @metadata.command(name="update")
3620
+ @click.option("--asset-id", required=True, help="Asset GUID")
3621
+ @click.option("--group", required=True, help="Business metadata group name")
3622
+ @click.option("--key", required=True, help="Attribute name to update")
3623
+ @click.option("--value", required=True, help="New attribute value")
3624
+ def update_custom_metadata(asset_id, group, key, value):
3625
+ """Update custom metadata (business metadata) for an asset.
3626
+
3627
+ Example: pvw uc metadata update --asset-id <guid> --group Governance --key DataOwner --value "Jane Smith"
3628
+ """
3629
+ client = UnifiedCatalogClient()
3630
+ args = {
3631
+ "--asset-id": [asset_id],
3632
+ "--group": [group],
3633
+ "--key": [key],
3634
+ "--value": [value]
3635
+ }
3636
+ response = client.update_custom_metadata(args)
3637
+
3638
+ console.print(f"[green]SUCCESS:[/green] Business metadata '{key}' updated in group '{group}' on asset '{asset_id}'")
3639
+ if response:
3640
+ _format_json_output(response)
3641
+
3642
+
3643
+ @metadata.command(name="delete")
3644
+ @click.option("--asset-id", required=True, help="Asset GUID")
3645
+ @click.option("--group", required=True, help="Business metadata group name to delete")
3646
+ @click.confirmation_option(prompt="Are you sure you want to delete this business metadata group?")
3647
+ def delete_custom_metadata(asset_id, group):
3648
+ """Delete custom metadata (business metadata) from an asset.
3649
+
3650
+ This removes the entire business metadata group from the asset.
3651
+ Example: pvw uc metadata delete --asset-id <guid> --group Governance
3652
+ """
3653
+ client = UnifiedCatalogClient()
3654
+ args = {
3655
+ "--asset-id": [asset_id],
3656
+ "--group": [group]
3657
+ }
3658
+ response = client.delete_custom_metadata(args)
3659
+
3660
+ console.print(f"[green]SUCCESS:[/green] Business metadata group '{group}' deleted from asset '{asset_id}'")
3661
+
3662
+
3663
+ # ========================================
3664
+ # CUSTOM ATTRIBUTES (NEW)
3665
+ # ========================================
3666
+
3667
+
3668
+ @uc.group()
3669
+ def attribute():
3670
+ """Manage custom attribute definitions."""
3671
+ pass
3672
+
3673
+
3674
+ @attribute.command(name="list")
3675
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
3676
+ def list_custom_attributes(output):
3677
+ """List all custom attribute definitions."""
3678
+ client = UnifiedCatalogClient()
3679
+ response = client.list_custom_attributes({})
3680
+
3681
+ if output == "json":
3682
+ console.print_json(json.dumps(response))
3683
+ else:
3684
+ if "value" in response and response["value"]:
3685
+ table = Table(title="[bold cyan]Custom Attribute Definitions[/bold cyan]", show_header=True)
3686
+ table.add_column("ID", style="cyan")
3687
+ table.add_column("Name", style="green")
3688
+ table.add_column("Data Type", style="yellow")
3689
+ table.add_column("Required", style="magenta")
3690
+ table.add_column("Description", style="white")
3691
+
3692
+ for item in response["value"]:
3693
+ table.add_row(
3694
+ item.get("id", "N/A"),
3695
+ item.get("name", "N/A"),
3696
+ item.get("dataType", "N/A"),
3697
+ "Yes" if item.get("required") else "No",
3698
+ item.get("description", "")[:50] + "..." if len(item.get("description", "")) > 50 else item.get("description", "")
3699
+ )
3700
+ console.print(table)
3701
+ else:
3702
+ console.print("[yellow]No custom attributes found[/yellow]")
3703
+
3704
+
3705
+ @attribute.command(name="get")
3706
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3707
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3708
+ def get_custom_attribute(attribute_id, output):
3709
+ """Get a specific custom attribute definition."""
3710
+ client = UnifiedCatalogClient()
3711
+ args = {"--attribute-id": [attribute_id]}
3712
+ response = client.get_custom_attribute(args)
3713
+
3714
+ if output == "json":
3715
+ _format_json_output(response)
3716
+ else:
3717
+ table = Table(title=f"[bold cyan]Attribute: {response.get('name', 'N/A')}[/bold cyan]")
3718
+ table.add_column("Property", style="cyan")
3719
+ table.add_column("Value", style="white")
3720
+
3721
+ for key, value in response.items():
3722
+ table.add_row(key, str(value))
3723
+ console.print(table)
3724
+
3725
+
3726
+ @attribute.command(name="create")
3727
+ @click.option("--name", required=True, help="Attribute name")
3728
+ @click.option("--data-type", required=True, help="Data type (string, number, boolean, date)")
3729
+ @click.option("--description", default="", help="Attribute description")
3730
+ @click.option("--required", is_flag=True, help="Is this attribute required?")
3731
+ def create_custom_attribute(name, data_type, description, required):
3732
+ """Create a new custom attribute definition."""
3733
+ client = UnifiedCatalogClient()
3734
+ args = {
3735
+ "--name": [name],
3736
+ "--data-type": [data_type],
3737
+ "--description": [description],
3738
+ "--required": ["true" if required else "false"]
3739
+ }
3740
+ response = client.create_custom_attribute(args)
3741
+
3742
+ console.print(f"[green]SUCCESS:[/green] Custom attribute created")
3743
+ _format_json_output(response)
3744
+
3745
+
3746
+ @attribute.command(name="update")
3747
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3748
+ @click.option("--name", help="New attribute name")
3749
+ @click.option("--description", help="New attribute description")
3750
+ @click.option("--required", type=bool, help="Is this attribute required? (true/false)")
3751
+ def update_custom_attribute(attribute_id, name, description, required):
3752
+ """Update an existing custom attribute definition."""
3753
+ client = UnifiedCatalogClient()
3754
+ args = {"--attribute-id": [attribute_id]}
3755
+
3756
+ if name:
3757
+ args["--name"] = [name]
3758
+ if description:
3759
+ args["--description"] = [description]
3760
+ if required is not None:
3761
+ args["--required"] = ["true" if required else "false"]
3762
+
3763
+ response = client.update_custom_attribute(args)
3764
+
3765
+ console.print(f"[green]SUCCESS:[/green] Custom attribute updated")
3766
+ _format_json_output(response)
3767
+
3768
+
3769
+ @attribute.command(name="delete")
3770
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3771
+ @click.confirmation_option(prompt="Are you sure you want to delete this attribute?")
3772
+ def delete_custom_attribute(attribute_id):
3773
+ """Delete a custom attribute definition."""
3774
+ client = UnifiedCatalogClient()
3775
+ args = {"--attribute-id": [attribute_id]}
3776
+ response = client.delete_custom_attribute(args)
3777
+
3778
+ console.print(f"[green]SUCCESS:[/green] Custom attribute '{attribute_id}' deleted")
3779
+
3780
+
3781
+ # ========================================
3782
+ # REQUESTS (Coming Soon)
3783
+ # ========================================
3784
+
3785
+
3786
+ @uc.group()
3787
+ def request():
3788
+ """Manage access requests (coming soon)."""
3789
+ pass
3790
+
3791
+
3792
+ @request.command(name="list")
3793
+ def list_requests():
3794
+ """List access requests (coming soon)."""
3795
+ console.print("[yellow]🚧 Access Requests are coming soon[/yellow]")
3796
+ console.print("This feature is under development for data access workflows")