pvw-cli 1.2.4__py3-none-any.whl → 1.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -491,7 +491,7 @@ def delete(product_id, yes):
491
491
  if result is None or (isinstance(result, dict) and not result.get("error")):
492
492
  console.print(f"[green] SUCCESS:[/green] Deleted data product '{product_id}'")
493
493
  elif isinstance(result, dict) and "error" in result:
494
- console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
494
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
495
495
  else:
496
496
  console.print(f"[green] SUCCESS:[/green] Deleted data product '{product_id}'")
497
497
  if result:
@@ -501,11 +501,288 @@ def delete(product_id, yes):
501
501
  console.print(f"[red]ERROR:[/red] {str(e)}")
502
502
 
503
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
+
504
783
  # ========================================
505
784
  # GLOSSARIES
506
785
  # ========================================
507
-
508
-
509
786
  @uc.group()
510
787
  def glossary():
511
788
  """Manage glossaries (for finding glossary GUIDs)."""
@@ -1657,6 +1934,119 @@ def update_terms_from_json(json_file, dry_run):
1657
1934
  console.print(f"[red]ERROR:[/red] {str(e)}")
1658
1935
 
1659
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
+
1660
2050
 
1661
2051
  # ========================================
1662
2052
  # OBJECTIVES AND KEY RESULTS (OKRs)
@@ -1797,6 +2187,116 @@ def show(objective_id):
1797
2187
  console.print(f"[red]ERROR:[/red] {str(e)}")
1798
2188
 
1799
2189
 
2190
+ @objective.command(name="query")
2191
+ @click.option("--ids", multiple=True, help="Filter by specific objective IDs (GUIDs)")
2192
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
2193
+ @click.option("--definition", help="Filter by definition text (partial match)")
2194
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
2195
+ @click.option("--status", type=click.Choice(["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"], case_sensitive=False),
2196
+ help="Filter by status")
2197
+ @click.option("--multi-status", multiple=True,
2198
+ type=click.Choice(["DRAFT", "ACTIVE", "COMPLETED", "ARCHIVED"], case_sensitive=False),
2199
+ help="Filter by multiple statuses")
2200
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
2201
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
2202
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
2203
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
2204
+ help="Sort direction")
2205
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2206
+ def query_objectives(ids, domain_ids, definition, owners, status, multi_status,
2207
+ skip, top, order_by_field, order_by_direction, output):
2208
+ """Query objectives with advanced filters.
2209
+
2210
+ Perform complex searches across OKR objectives using multiple filter criteria.
2211
+ Supports pagination and custom sorting.
2212
+
2213
+ Examples:
2214
+ # Find all objectives in a specific domain
2215
+ pvw uc objective query --domain-ids <domain-guid>
2216
+
2217
+ # Search by definition text
2218
+ pvw uc objective query --definition "customer satisfaction"
2219
+
2220
+ # Filter by owner and status
2221
+ pvw uc objective query --owners <user-guid> --status ACTIVE
2222
+
2223
+ # Find all completed objectives
2224
+ pvw uc objective query --multi-status COMPLETED ARCHIVED
2225
+
2226
+ # Pagination example
2227
+ pvw uc objective query --skip 0 --top 50 --order-by-field name --order-by-direction asc
2228
+ """
2229
+ try:
2230
+ client = UnifiedCatalogClient()
2231
+ args = {}
2232
+
2233
+ # Build args dict from parameters
2234
+ if ids:
2235
+ args["--ids"] = list(ids)
2236
+ if domain_ids:
2237
+ args["--domain-ids"] = list(domain_ids)
2238
+ if definition:
2239
+ args["--definition"] = [definition]
2240
+ if owners:
2241
+ args["--owners"] = list(owners)
2242
+ if status:
2243
+ args["--status"] = [status]
2244
+ if multi_status:
2245
+ args["--multi-status"] = list(multi_status)
2246
+ if skip:
2247
+ args["--skip"] = [str(skip)]
2248
+ if top:
2249
+ args["--top"] = [str(top)]
2250
+ if order_by_field:
2251
+ args["--order-by-field"] = [order_by_field]
2252
+ args["--order-by-direction"] = [order_by_direction]
2253
+
2254
+ result = client.query_objectives(args)
2255
+
2256
+ if output == "json":
2257
+ console.print_json(data=result)
2258
+ else:
2259
+ objectives = result.get("value", []) if result else []
2260
+
2261
+ if not objectives:
2262
+ console.print("[yellow]No objectives found matching the query.[/yellow]")
2263
+ return
2264
+
2265
+ # Check for pagination
2266
+ next_link = result.get("nextLink")
2267
+ if next_link:
2268
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2269
+
2270
+ table = Table(title=f"Query Results ({len(objectives)} found)", show_header=True)
2271
+ table.add_column("Name", style="cyan")
2272
+ table.add_column("ID", style="dim", no_wrap=True)
2273
+ table.add_column("Domain", style="yellow", no_wrap=True)
2274
+ table.add_column("Status", style="white")
2275
+ table.add_column("Owner", style="green", no_wrap=True)
2276
+
2277
+ for obj in objectives:
2278
+ owner_display = "N/A"
2279
+ if obj.get("owner"):
2280
+ owner_display = obj["owner"].get("id", "N/A")[:13] + "..."
2281
+
2282
+ table.add_row(
2283
+ obj.get("name", "N/A"),
2284
+ obj.get("id", "N/A")[:13] + "...",
2285
+ obj.get("domain", "N/A")[:13] + "...",
2286
+ obj.get("status", "N/A"),
2287
+ owner_display
2288
+ )
2289
+
2290
+ console.print(table)
2291
+
2292
+ # Show pagination info
2293
+ if skip > 0 or next_link:
2294
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(objectives)}[/dim]")
2295
+
2296
+ except Exception as e:
2297
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2298
+
2299
+
1800
2300
  # ========================================
1801
2301
  # CRITICAL DATA ELEMENTS (CDEs)
1802
2302
  # ========================================
@@ -1942,6 +2442,272 @@ def show(cde_id):
1942
2442
  console.print(f"[red]ERROR:[/red] {str(e)}")
1943
2443
 
1944
2444
 
2445
+ @cde.command(name="add-relationship")
2446
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2447
+ @click.option("--entity-type", required=True,
2448
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2449
+ help="Type of entity to relate to")
2450
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to relate to")
2451
+ @click.option("--asset-id", help="Asset ID (GUID) - defaults to entity-id if not provided")
2452
+ @click.option("--relationship-type", default="Related", help="Relationship type (default: Related)")
2453
+ @click.option("--description", default="", help="Description of the relationship")
2454
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2455
+ def add_cde_relationship(cde_id, entity_type, entity_id, asset_id, relationship_type, description, output):
2456
+ """Create a relationship for a critical data element.
2457
+
2458
+ Links a CDE to another entity like a critical data column, term, or data product.
2459
+
2460
+ Examples:
2461
+ pvw uc cde add-relationship --cde-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
2462
+ pvw uc cde add-relationship --cde-id <id> --entity-type TERM --entity-id <term-id> --description "Primary term"
2463
+ """
2464
+ try:
2465
+ client = UnifiedCatalogClient()
2466
+ args = {
2467
+ "--cde-id": [cde_id],
2468
+ "--entity-type": [entity_type],
2469
+ "--entity-id": [entity_id],
2470
+ "--relationship-type": [relationship_type],
2471
+ "--description": [description]
2472
+ }
2473
+
2474
+ if asset_id:
2475
+ args["--asset-id"] = [asset_id]
2476
+
2477
+ result = client.create_cde_relationship(args)
2478
+
2479
+ if output == "json":
2480
+ console.print_json(data=result)
2481
+ else:
2482
+ if result and isinstance(result, dict):
2483
+ console.print("[green]SUCCESS:[/green] Created CDE relationship")
2484
+ table = Table(title="CDE Relationship", show_header=True)
2485
+ table.add_column("Property", style="cyan")
2486
+ table.add_column("Value", style="white")
2487
+
2488
+ table.add_row("Entity ID", result.get("entityId", "N/A"))
2489
+ table.add_row("Relationship Type", result.get("relationshipType", "N/A"))
2490
+ table.add_row("Description", result.get("description", "N/A"))
2491
+
2492
+ if "systemData" in result:
2493
+ sys_data = result["systemData"]
2494
+ table.add_row("Created By", sys_data.get("createdBy", "N/A"))
2495
+ table.add_row("Created At", sys_data.get("createdAt", "N/A"))
2496
+
2497
+ console.print(table)
2498
+ else:
2499
+ console.print("[green]SUCCESS:[/green] Created CDE relationship")
2500
+
2501
+ except Exception as e:
2502
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2503
+
2504
+
2505
+ @cde.command(name="list-relationships")
2506
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2507
+ @click.option("--entity-type",
2508
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2509
+ help="Filter by entity type (optional)")
2510
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2511
+ def list_cde_relationships(cde_id, entity_type, output):
2512
+ """List relationships for a critical data element.
2513
+
2514
+ Shows all entities linked to this CDE, optionally filtered by type.
2515
+
2516
+ Examples:
2517
+ pvw uc cde list-relationships --cde-id <id>
2518
+ pvw uc cde list-relationships --cde-id <id> --entity-type CRITICALDATACOLUMN
2519
+ """
2520
+ try:
2521
+ client = UnifiedCatalogClient()
2522
+ args = {"--cde-id": [cde_id]}
2523
+
2524
+ if entity_type:
2525
+ args["--entity-type"] = [entity_type]
2526
+
2527
+ result = client.get_cde_relationships(args)
2528
+
2529
+ if output == "json":
2530
+ console.print_json(data=result)
2531
+ else:
2532
+ relationships = result.get("value", []) if result else []
2533
+
2534
+ if not relationships:
2535
+ console.print(f"[yellow]No relationships found for CDE '{cde_id}'[/yellow]")
2536
+ return
2537
+
2538
+ table = Table(title=f"CDE Relationships ({len(relationships)} found)", show_header=True)
2539
+ table.add_column("Entity ID", style="cyan")
2540
+ table.add_column("Relationship Type", style="white")
2541
+ table.add_column("Description", style="white")
2542
+ table.add_column("Created", style="dim")
2543
+
2544
+ for rel in relationships:
2545
+ table.add_row(
2546
+ rel.get("entityId", "N/A"),
2547
+ rel.get("relationshipType", "N/A"),
2548
+ rel.get("description", "")[:50] + ("..." if len(rel.get("description", "")) > 50 else ""),
2549
+ rel.get("systemData", {}).get("createdAt", "N/A")[:10]
2550
+ )
2551
+
2552
+ console.print(table)
2553
+
2554
+ except Exception as e:
2555
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2556
+
2557
+
2558
+ @cde.command(name="remove-relationship")
2559
+ @click.option("--cde-id", required=True, help="Critical data element ID (GUID)")
2560
+ @click.option("--entity-type", required=True,
2561
+ type=click.Choice(["CRITICALDATACOLUMN", "TERM", "DATAASSET", "DATAPRODUCT"], case_sensitive=False),
2562
+ help="Type of entity to unlink")
2563
+ @click.option("--entity-id", required=True, help="Entity ID (GUID) to unlink")
2564
+ @click.option("--confirm/--no-confirm", default=True, help="Ask for confirmation before deleting")
2565
+ def remove_cde_relationship(cde_id, entity_type, entity_id, confirm):
2566
+ """Delete a relationship between a CDE and an entity.
2567
+
2568
+ Removes the link between a critical data element and a specific entity.
2569
+
2570
+ Examples:
2571
+ pvw uc cde remove-relationship --cde-id <id> --entity-type CRITICALDATACOLUMN --entity-id <col-id>
2572
+ pvw uc cde remove-relationship --cde-id <id> --entity-type TERM --entity-id <term-id> --no-confirm
2573
+ """
2574
+ try:
2575
+ if confirm:
2576
+ confirm = click.confirm(
2577
+ f"Are you sure you want to delete CDE relationship to {entity_type} '{entity_id}'?",
2578
+ default=False
2579
+ )
2580
+ if not confirm:
2581
+ console.print("[yellow]Deletion cancelled.[/yellow]")
2582
+ return
2583
+
2584
+ client = UnifiedCatalogClient()
2585
+ args = {
2586
+ "--cde-id": [cde_id],
2587
+ "--entity-type": [entity_type],
2588
+ "--entity-id": [entity_id]
2589
+ }
2590
+
2591
+ result = client.delete_cde_relationship(args)
2592
+
2593
+ # DELETE returns 204 No Content on success
2594
+ if result is None or (isinstance(result, dict) and not result.get("error")):
2595
+ console.print(f"[green]SUCCESS:[/green] Deleted CDE relationship to {entity_type} '{entity_id}'")
2596
+ elif isinstance(result, dict) and "error" in result:
2597
+ console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
2598
+ else:
2599
+ console.print(f"[green]SUCCESS:[/green] Deleted CDE relationship")
2600
+
2601
+ except Exception as e:
2602
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2603
+
2604
+
2605
+ @cde.command(name="query")
2606
+ @click.option("--ids", multiple=True, help="Filter by specific CDE IDs (GUIDs)")
2607
+ @click.option("--domain-ids", multiple=True, help="Filter by domain IDs (GUIDs)")
2608
+ @click.option("--name-keyword", help="Filter by name keyword (partial match)")
2609
+ @click.option("--owners", multiple=True, help="Filter by owner IDs (GUIDs)")
2610
+ @click.option("--status", type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
2611
+ help="Filter by status")
2612
+ @click.option("--multi-status", multiple=True,
2613
+ type=click.Choice(["DRAFT", "PUBLISHED", "EXPIRED"], case_sensitive=False),
2614
+ help="Filter by multiple statuses")
2615
+ @click.option("--skip", type=int, default=0, help="Number of items to skip (pagination)")
2616
+ @click.option("--top", type=int, default=100, help="Number of items to return (max 1000)")
2617
+ @click.option("--order-by-field", help="Field to sort by (e.g., 'name', 'status')")
2618
+ @click.option("--order-by-direction", type=click.Choice(["asc", "desc"]), default="asc",
2619
+ help="Sort direction")
2620
+ @click.option("--output", default="table", type=click.Choice(["json", "table"]), help="Output format")
2621
+ def query_cdes(ids, domain_ids, name_keyword, owners, status, multi_status,
2622
+ skip, top, order_by_field, order_by_direction, output):
2623
+ """Query critical data elements with advanced filters.
2624
+
2625
+ Perform complex searches across CDEs using multiple filter criteria.
2626
+ Supports pagination and custom sorting.
2627
+
2628
+ Examples:
2629
+ # Find all CDEs in a specific domain
2630
+ pvw uc cde query --domain-ids <domain-guid>
2631
+
2632
+ # Search by keyword
2633
+ pvw uc cde query --name-keyword "customer"
2634
+
2635
+ # Filter by owner and status
2636
+ pvw uc cde query --owners <user-guid> --status PUBLISHED
2637
+
2638
+ # Find all published or expired CDEs
2639
+ pvw uc cde query --multi-status PUBLISHED EXPIRED
2640
+
2641
+ # Pagination example
2642
+ pvw uc cde query --skip 0 --top 50 --order-by-field name --order-by-direction desc
2643
+ """
2644
+ try:
2645
+ client = UnifiedCatalogClient()
2646
+ args = {}
2647
+
2648
+ # Build args dict from parameters
2649
+ if ids:
2650
+ args["--ids"] = list(ids)
2651
+ if domain_ids:
2652
+ args["--domain-ids"] = list(domain_ids)
2653
+ if name_keyword:
2654
+ args["--name-keyword"] = [name_keyword]
2655
+ if owners:
2656
+ args["--owners"] = list(owners)
2657
+ if status:
2658
+ args["--status"] = [status]
2659
+ if multi_status:
2660
+ args["--multi-status"] = list(multi_status)
2661
+ if skip:
2662
+ args["--skip"] = [str(skip)]
2663
+ if top:
2664
+ args["--top"] = [str(top)]
2665
+ if order_by_field:
2666
+ args["--order-by-field"] = [order_by_field]
2667
+ args["--order-by-direction"] = [order_by_direction]
2668
+
2669
+ result = client.query_critical_data_elements(args)
2670
+
2671
+ if output == "json":
2672
+ console.print_json(data=result)
2673
+ else:
2674
+ cdes = result.get("value", []) if result else []
2675
+
2676
+ if not cdes:
2677
+ console.print("[yellow]No critical data elements found matching the query.[/yellow]")
2678
+ return
2679
+
2680
+ # Check for pagination
2681
+ next_link = result.get("nextLink")
2682
+ if next_link:
2683
+ console.print(f"[dim]Note: More results available (nextLink provided)[/dim]\n")
2684
+
2685
+ table = Table(title=f"Query Results ({len(cdes)} found)", show_header=True)
2686
+ table.add_column("Name", style="cyan")
2687
+ table.add_column("ID", style="dim", no_wrap=True)
2688
+ table.add_column("Domain", style="yellow", no_wrap=True)
2689
+ table.add_column("Status", style="white")
2690
+ table.add_column("Data Type", style="green")
2691
+
2692
+ for cde in cdes:
2693
+ table.add_row(
2694
+ cde.get("name", "N/A"),
2695
+ cde.get("id", "N/A")[:13] + "...",
2696
+ cde.get("domain", "N/A")[:13] + "...",
2697
+ cde.get("status", "N/A"),
2698
+ cde.get("dataType", "N/A")
2699
+ )
2700
+
2701
+ console.print(table)
2702
+
2703
+ # Show pagination info
2704
+ if skip > 0 or next_link:
2705
+ console.print(f"\n[dim]Showing items {skip + 1} to {skip + len(cdes)}[/dim]")
2706
+
2707
+ except Exception as e:
2708
+ console.print(f"[red]ERROR:[/red] {str(e)}")
2709
+
2710
+
1945
2711
  # ========================================
1946
2712
  # HEALTH MANAGEMENT - IMPLEMENTED!
1947
2713
  # ========================================
@@ -1952,21 +2718,575 @@ uc.add_command(health_commands, name="health")
1952
2718
 
1953
2719
 
1954
2720
  # ========================================
1955
- # CUSTOM ATTRIBUTES (Coming Soon)
2721
+ # DATA POLICIES (NEW)
2722
+ # ========================================
2723
+
2724
+
2725
+ @uc.group()
2726
+ def policy():
2727
+ """Manage data governance policies."""
2728
+ pass
2729
+
2730
+
2731
+ @policy.command(name="list")
2732
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
2733
+ def list_policies(output):
2734
+ """List all data governance policies."""
2735
+ client = UnifiedCatalogClient()
2736
+ response = client.list_policies({})
2737
+
2738
+ if output == "json":
2739
+ console.print_json(json.dumps(response))
2740
+ else:
2741
+ # API returns 'values' (plural), not 'value'
2742
+ policies = response.get("values", response.get("value", []))
2743
+
2744
+ if policies:
2745
+ table = Table(title="[bold cyan]Data Governance Policies[/bold cyan]", show_header=True)
2746
+ table.add_column("ID", style="cyan", no_wrap=True)
2747
+ table.add_column("Name", style="green")
2748
+ table.add_column("Entity Type", style="yellow")
2749
+ table.add_column("Entity ID", style="magenta", no_wrap=True)
2750
+ table.add_column("Rules", style="white")
2751
+
2752
+ for item in policies:
2753
+ props = item.get("properties", {})
2754
+ entity = props.get("entity", {})
2755
+ entity_type = entity.get("type", "N/A")
2756
+ entity_ref = entity.get("referenceName", "N/A")
2757
+
2758
+ # Count rules
2759
+ decision_rules = len(props.get("decisionRules", []))
2760
+ attribute_rules = len(props.get("attributeRules", []))
2761
+ rules_summary = f"{decision_rules} decision, {attribute_rules} attribute"
2762
+
2763
+ table.add_row(
2764
+ item.get("id", "N/A")[:36], # Show only GUID
2765
+ item.get("name", "N/A"),
2766
+ entity_type.replace("Reference", ""), # Clean up type name
2767
+ entity_ref[:36], # Show only GUID
2768
+ rules_summary
2769
+ )
2770
+
2771
+ console.print(table)
2772
+ console.print(f"\n[dim]Total: {len(policies)} policy/policies[/dim]")
2773
+ else:
2774
+ console.print("[yellow]No policies found[/yellow]")
2775
+
2776
+
2777
+ @policy.command(name="get")
2778
+ @click.option("--policy-id", required=True, help="Policy ID")
2779
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
2780
+ def get_policy(policy_id, output):
2781
+ """Get a specific data governance policy by ID."""
2782
+ client = UnifiedCatalogClient()
2783
+
2784
+ # Get all policies and filter (since GET by ID returns 404)
2785
+ all_policies = client.list_policies({})
2786
+ policies = all_policies.get("values", all_policies.get("value", []))
2787
+
2788
+ # Find the requested policy
2789
+ policy = next((p for p in policies if p.get("id") == policy_id), None)
2790
+
2791
+ if not policy:
2792
+ console.print(f"[red]ERROR:[/red] Policy with ID {policy_id} not found")
2793
+ return
2794
+
2795
+ if output == "json":
2796
+ _format_json_output(policy)
2797
+ else:
2798
+ # Display policy in formatted view
2799
+ props = policy.get("properties", {})
2800
+ entity = props.get("entity", {})
2801
+
2802
+ console.print(f"\n[bold cyan]Policy Details[/bold cyan]")
2803
+ console.print(f"[bold]ID:[/bold] {policy.get('id')}")
2804
+ console.print(f"[bold]Name:[/bold] {policy.get('name')}")
2805
+ console.print(f"[bold]Version:[/bold] {policy.get('version', 0)}")
2806
+
2807
+ console.print(f"\n[bold cyan]Entity[/bold cyan]")
2808
+ console.print(f"[bold]Type:[/bold] {entity.get('type', 'N/A')}")
2809
+ console.print(f"[bold]Reference:[/bold] {entity.get('referenceName', 'N/A')}")
2810
+ console.print(f"[bold]Parent:[/bold] {props.get('parentEntityName', 'N/A')}")
2811
+
2812
+ # Decision Rules
2813
+ decision_rules = props.get("decisionRules", [])
2814
+ if decision_rules:
2815
+ console.print(f"\n[bold cyan]Decision Rules ({len(decision_rules)})[/bold cyan]")
2816
+ for i, rule in enumerate(decision_rules, 1):
2817
+ console.print(f"\n [bold]Rule {i}:[/bold] {rule.get('kind', 'N/A')}")
2818
+ console.print(f" [bold]Effect:[/bold] {rule.get('effect', 'N/A')}")
2819
+ if "dnfCondition" in rule:
2820
+ console.print(f" [bold]Conditions:[/bold] {len(rule['dnfCondition'])} clause(s)")
2821
+
2822
+ # Attribute Rules
2823
+ attribute_rules = props.get("attributeRules", [])
2824
+ if attribute_rules:
2825
+ console.print(f"\n[bold cyan]Attribute Rules ({len(attribute_rules)})[/bold cyan]")
2826
+ for i, rule in enumerate(attribute_rules, 1):
2827
+ console.print(f"\n [bold]Rule {i}:[/bold] {rule.get('name', rule.get('id', 'N/A'))}")
2828
+ if "dnfCondition" in rule:
2829
+ conditions = rule.get("dnfCondition", [])
2830
+ console.print(f" [bold]Conditions:[/bold] {len(conditions)} clause(s)")
2831
+ for j, clause in enumerate(conditions[:3], 1): # Show first 3
2832
+ if clause:
2833
+ attr = clause[0] if isinstance(clause, list) else clause
2834
+ console.print(f" {j}. {attr.get('attributeName', 'N/A')}")
2835
+ if len(conditions) > 3:
2836
+ console.print(f" ... and {len(conditions) - 3} more")
2837
+
2838
+ console.print()
2839
+
2840
+
2841
+
2842
+ @policy.command(name="create")
2843
+ @click.option("--name", required=True, help="Policy name")
2844
+ @click.option("--policy-type", required=True, help="Policy type (e.g., access, retention)")
2845
+ @click.option("--description", default="", help="Policy description")
2846
+ @click.option("--status", default="active", help="Policy status (active, draft)")
2847
+ def create_policy(name, policy_type, description, status):
2848
+ """Create a new data governance policy."""
2849
+ client = UnifiedCatalogClient()
2850
+ args = {
2851
+ "--name": [name],
2852
+ "--policy-type": [policy_type],
2853
+ "--description": [description],
2854
+ "--status": [status]
2855
+ }
2856
+ response = client.create_policy(args)
2857
+
2858
+ console.print(f"[green]SUCCESS:[/green] Policy created")
2859
+ _format_json_output(response)
2860
+
2861
+
2862
+ @policy.command(name="update")
2863
+ @click.option("--policy-id", required=True, help="Policy ID")
2864
+ @click.option("--name", help="New policy name")
2865
+ @click.option("--description", help="New policy description")
2866
+ @click.option("--status", help="New policy status")
2867
+ def update_policy(policy_id, name, description, status):
2868
+ """Update an existing data governance policy."""
2869
+ client = UnifiedCatalogClient()
2870
+ args = {"--policy-id": [policy_id]}
2871
+
2872
+ if name:
2873
+ args["--name"] = [name]
2874
+ if description:
2875
+ args["--description"] = [description]
2876
+ if status:
2877
+ args["--status"] = [status]
2878
+
2879
+ response = client.update_policy(args)
2880
+
2881
+ console.print(f"[green]SUCCESS:[/green] Policy updated")
2882
+ _format_json_output(response)
2883
+
2884
+
2885
+ @policy.command(name="delete")
2886
+ @click.option("--policy-id", required=True, help="Policy ID")
2887
+ @click.confirmation_option(prompt="Are you sure you want to delete this policy?")
2888
+ def delete_policy(policy_id):
2889
+ """Delete a data governance policy."""
2890
+ client = UnifiedCatalogClient()
2891
+ args = {"--policy-id": [policy_id]}
2892
+ response = client.delete_policy(args)
2893
+
2894
+ console.print(f"[green]SUCCESS:[/green] Policy '{policy_id}' deleted")
2895
+
2896
+
2897
+ # ========================================
2898
+ # CUSTOM METADATA (NEW)
2899
+ # ========================================
2900
+
2901
+
2902
+ @uc.group()
2903
+ def metadata():
2904
+ """Manage custom metadata for assets."""
2905
+ pass
2906
+
2907
+
2908
+ @metadata.command(name="list")
2909
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
2910
+ @click.option("--fallback/--no-fallback", default=True, help="Fallback to Business Metadata if UC is empty")
2911
+ def list_custom_metadata(output, fallback):
2912
+ """List all custom metadata definitions.
2913
+
2914
+ Uses Atlas API to get Business Metadata definitions.
2915
+ With fallback enabled, shows user-friendly table format.
2916
+ """
2917
+ client = UnifiedCatalogClient()
2918
+ response = client.list_custom_metadata({})
2919
+
2920
+ # Check if UC API returned business metadata (Atlas returns businessMetadataDefs)
2921
+ has_uc_data = (response and "businessMetadataDefs" in response
2922
+ and response["businessMetadataDefs"])
2923
+
2924
+ if output == "json":
2925
+ if has_uc_data:
2926
+ console.print_json(json.dumps(response))
2927
+ elif fallback:
2928
+ # Fallback message (though Atlas API should always return something)
2929
+ console.print("[dim]No business metadata found.[/dim]\n")
2930
+ console.print_json(json.dumps({"businessMetadataDefs": []}))
2931
+ else:
2932
+ console.print_json(json.dumps(response))
2933
+ else:
2934
+ # Table output
2935
+ if has_uc_data:
2936
+ biz_metadata = response.get('businessMetadataDefs', [])
2937
+
2938
+ if biz_metadata:
2939
+ table = Table(title="[bold green]Business Metadata Attributes[/bold green]", show_header=True)
2940
+ table.add_column("Attribute Name", style="green", no_wrap=True)
2941
+ table.add_column("Group", style="cyan")
2942
+ table.add_column("Type", style="yellow")
2943
+ table.add_column("Scope", style="magenta", max_width=25)
2944
+ table.add_column("Description", style="white", max_width=30)
2945
+
2946
+ total_attrs = 0
2947
+ for group in biz_metadata:
2948
+ group_name = group.get('name', 'N/A')
2949
+ attributes = group.get('attributeDefs', [])
2950
+
2951
+ # Parse group-level scope
2952
+ group_scope = "N/A"
2953
+ options = group.get('options', {})
2954
+ if 'dataGovernanceOptions' in options:
2955
+ try:
2956
+ dg_opts_str = options.get('dataGovernanceOptions', '{}')
2957
+ dg_opts = json.loads(dg_opts_str) if isinstance(dg_opts_str, str) else dg_opts_str
2958
+ applicable = dg_opts.get('applicableConstructs', [])
2959
+ if applicable:
2960
+ # Categorize scope
2961
+ has_business_concept = any('businessConcept' in c or 'domain' in c for c in applicable)
2962
+ has_dataset = any('dataset' in c.lower() for c in applicable)
2963
+
2964
+ if has_business_concept and has_dataset:
2965
+ group_scope = "Universal (Concept + Dataset)"
2966
+ elif has_business_concept:
2967
+ group_scope = "Business Concept"
2968
+ elif has_dataset:
2969
+ group_scope = "Data Asset"
2970
+ else:
2971
+ # Show first 2 constructs
2972
+ scope_parts = []
2973
+ for construct in applicable[:2]:
2974
+ if ':' in construct:
2975
+ scope_parts.append(construct.split(':')[0])
2976
+ else:
2977
+ scope_parts.append(construct)
2978
+ group_scope = ', '.join(scope_parts)
2979
+ except:
2980
+ pass
2981
+
2982
+ for attr in attributes:
2983
+ total_attrs += 1
2984
+ attr_name = attr.get('name', 'N/A')
2985
+ attr_type = attr.get('typeName', 'N/A')
2986
+
2987
+ # Simplify enum types
2988
+ if 'ATTRIBUTE_ENUM_' in attr_type:
2989
+ attr_type = 'Enum'
2990
+
2991
+ attr_desc = attr.get('description', '')
2992
+
2993
+ # Check if attribute has custom scope
2994
+ attr_scope = group_scope
2995
+ attr_opts = attr.get('options', {})
2996
+
2997
+ # Check dataGovernanceOptions first
2998
+ if 'dataGovernanceOptions' in attr_opts:
2999
+ try:
3000
+ attr_dg_str = attr_opts.get('dataGovernanceOptions', '{}')
3001
+ attr_dg = json.loads(attr_dg_str) if isinstance(attr_dg_str, str) else attr_dg_str
3002
+ inherit = attr_dg.get('inheritApplicableConstructsFromGroup', True)
3003
+ if not inherit:
3004
+ attr_applicable = attr_dg.get('applicableConstructs', [])
3005
+ if attr_applicable:
3006
+ # Categorize custom scope
3007
+ has_business_concept = any('businessConcept' in c or 'domain' in c for c in attr_applicable)
3008
+ has_dataset = any('dataset' in c.lower() for c in attr_applicable)
3009
+
3010
+ if has_business_concept and has_dataset:
3011
+ attr_scope = "Universal"
3012
+ elif has_business_concept:
3013
+ attr_scope = "Business Concept"
3014
+ elif has_dataset:
3015
+ attr_scope = "Data Asset"
3016
+ else:
3017
+ attr_scope = f"Custom ({len(attr_applicable)})"
3018
+ except:
3019
+ pass
3020
+
3021
+ # Fallback: Check applicableEntityTypes (older format)
3022
+ if attr_scope == "N/A" and 'applicableEntityTypes' in attr_opts:
3023
+ try:
3024
+ entity_types_str = attr_opts.get('applicableEntityTypes', '[]')
3025
+ # Parse if string, otherwise use as-is
3026
+ if isinstance(entity_types_str, str):
3027
+ entity_types = json.loads(entity_types_str)
3028
+ else:
3029
+ entity_types = entity_types_str
3030
+
3031
+ if entity_types and isinstance(entity_types, list):
3032
+ # Check if entity types are data assets (tables, etc.)
3033
+ if any('table' in et.lower() or 'database' in et.lower() or 'file' in et.lower()
3034
+ for et in entity_types):
3035
+ attr_scope = "Data Asset"
3036
+ else:
3037
+ attr_scope = f"Assets ({len(entity_types)} types)"
3038
+ except Exception as e:
3039
+ # Silently fail but could log for debugging
3040
+ pass
3041
+
3042
+ table.add_row(
3043
+ attr_name,
3044
+ group_name,
3045
+ attr_type,
3046
+ attr_scope,
3047
+ attr_desc[:30] + "..." if len(attr_desc) > 30 else attr_desc
3048
+ )
3049
+
3050
+ console.print(table)
3051
+ console.print(f"\n[cyan]Total:[/cyan] {total_attrs} business metadata attribute(s) in {len(biz_metadata)} group(s)")
3052
+ console.print("\n[dim]Legend:[/dim]")
3053
+ console.print(" [magenta]Business Concept[/magenta] = Applies to Terms, Domains, Business Rules")
3054
+ console.print(" [magenta]Data Asset[/magenta] = Applies to Tables, Files, Databases")
3055
+ console.print(" [magenta]Universal[/magenta] = Applies to both Concepts and Assets")
3056
+ else:
3057
+ console.print("[yellow]No business metadata found[/yellow]")
3058
+ else:
3059
+ console.print("[yellow]No business metadata found[/yellow]")
3060
+
3061
+
3062
+ @metadata.command(name="get")
3063
+ @click.option("--asset-id", required=True, help="Asset GUID")
3064
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3065
+ def get_custom_metadata(asset_id, output):
3066
+ """Get custom metadata (business metadata) for a specific asset."""
3067
+ client = UnifiedCatalogClient()
3068
+ args = {"--asset-id": [asset_id]}
3069
+ response = client.get_custom_metadata(args)
3070
+
3071
+ if output == "json":
3072
+ # Extract businessAttributes from entity response
3073
+ # Note: API returns "businessAttributes" not "businessMetadata"
3074
+ if response and "entity" in response:
3075
+ business_metadata = response["entity"].get("businessAttributes", {})
3076
+ _format_json_output(business_metadata)
3077
+ elif response and isinstance(response, dict):
3078
+ business_metadata = response.get("businessAttributes", {})
3079
+ _format_json_output(business_metadata)
3080
+ else:
3081
+ _format_json_output({})
3082
+ else:
3083
+ table = Table(title=f"[bold cyan]Business Metadata for Asset: {asset_id}[/bold cyan]")
3084
+ table.add_column("Group", style="cyan")
3085
+ table.add_column("Attribute", style="green")
3086
+ table.add_column("Value", style="white")
3087
+
3088
+ if response and "entity" in response:
3089
+ business_metadata = response["entity"].get("businessAttributes", {})
3090
+ if business_metadata:
3091
+ for group_name, attributes in business_metadata.items():
3092
+ if isinstance(attributes, dict):
3093
+ for attr_name, attr_value in attributes.items():
3094
+ table.add_row(group_name, attr_name, str(attr_value))
3095
+ elif response and isinstance(response, dict):
3096
+ business_metadata = response.get("businessAttributes", {})
3097
+ if business_metadata:
3098
+ for group_name, attributes in business_metadata.items():
3099
+ if isinstance(attributes, dict):
3100
+ for attr_name, attr_value in attributes.items():
3101
+ table.add_row(group_name, attr_name, str(attr_value))
3102
+
3103
+ console.print(table)
3104
+
3105
+
3106
+ @metadata.command(name="add")
3107
+ @click.option("--asset-id", required=True, help="Asset GUID")
3108
+ @click.option("--group", required=True, help="Business metadata group name (e.g., 'Governance', 'Privacy')")
3109
+ @click.option("--key", required=True, help="Attribute name")
3110
+ @click.option("--value", required=True, help="Attribute value")
3111
+ def add_custom_metadata(asset_id, group, key, value):
3112
+ """Add custom metadata (business metadata) to an asset.
3113
+
3114
+ Example: pvw uc metadata add --asset-id <guid> --group Governance --key DataOwner --value "John Doe"
3115
+ """
3116
+ client = UnifiedCatalogClient()
3117
+ args = {
3118
+ "--asset-id": [asset_id],
3119
+ "--group": [group],
3120
+ "--key": [key],
3121
+ "--value": [value]
3122
+ }
3123
+ response = client.add_custom_metadata(args)
3124
+
3125
+ console.print(f"[green]SUCCESS:[/green] Business metadata '{key}' added to group '{group}' on asset '{asset_id}'")
3126
+ if response:
3127
+ _format_json_output(response)
3128
+
3129
+
3130
+ @metadata.command(name="update")
3131
+ @click.option("--asset-id", required=True, help="Asset GUID")
3132
+ @click.option("--group", required=True, help="Business metadata group name")
3133
+ @click.option("--key", required=True, help="Attribute name to update")
3134
+ @click.option("--value", required=True, help="New attribute value")
3135
+ def update_custom_metadata(asset_id, group, key, value):
3136
+ """Update custom metadata (business metadata) for an asset.
3137
+
3138
+ Example: pvw uc metadata update --asset-id <guid> --group Governance --key DataOwner --value "Jane Smith"
3139
+ """
3140
+ client = UnifiedCatalogClient()
3141
+ args = {
3142
+ "--asset-id": [asset_id],
3143
+ "--group": [group],
3144
+ "--key": [key],
3145
+ "--value": [value]
3146
+ }
3147
+ response = client.update_custom_metadata(args)
3148
+
3149
+ console.print(f"[green]SUCCESS:[/green] Business metadata '{key}' updated in group '{group}' on asset '{asset_id}'")
3150
+ if response:
3151
+ _format_json_output(response)
3152
+
3153
+
3154
+ @metadata.command(name="delete")
3155
+ @click.option("--asset-id", required=True, help="Asset GUID")
3156
+ @click.option("--group", required=True, help="Business metadata group name to delete")
3157
+ @click.confirmation_option(prompt="Are you sure you want to delete this business metadata group?")
3158
+ def delete_custom_metadata(asset_id, group):
3159
+ """Delete custom metadata (business metadata) from an asset.
3160
+
3161
+ This removes the entire business metadata group from the asset.
3162
+ Example: pvw uc metadata delete --asset-id <guid> --group Governance
3163
+ """
3164
+ client = UnifiedCatalogClient()
3165
+ args = {
3166
+ "--asset-id": [asset_id],
3167
+ "--group": [group]
3168
+ }
3169
+ response = client.delete_custom_metadata(args)
3170
+
3171
+ console.print(f"[green]SUCCESS:[/green] Business metadata group '{group}' deleted from asset '{asset_id}'")
3172
+
3173
+
3174
+ # ========================================
3175
+ # CUSTOM ATTRIBUTES (NEW)
1956
3176
  # ========================================
1957
3177
 
1958
3178
 
1959
3179
  @uc.group()
1960
3180
  def attribute():
1961
- """Manage custom attributes (coming soon)."""
3181
+ """Manage custom attribute definitions."""
1962
3182
  pass
1963
3183
 
1964
3184
 
1965
3185
  @attribute.command(name="list")
1966
- def list_attributes():
1967
- """List custom attributes (coming soon)."""
1968
- console.print("[yellow]🚧 Custom Attributes are coming soon[/yellow]")
1969
- console.print("This feature is under development in Microsoft Purview")
3186
+ @click.option("--output", type=click.Choice(["table", "json"]), default="table", help="Output format")
3187
+ def list_custom_attributes(output):
3188
+ """List all custom attribute definitions."""
3189
+ client = UnifiedCatalogClient()
3190
+ response = client.list_custom_attributes({})
3191
+
3192
+ if output == "json":
3193
+ console.print_json(json.dumps(response))
3194
+ else:
3195
+ if "value" in response and response["value"]:
3196
+ table = Table(title="[bold cyan]Custom Attribute Definitions[/bold cyan]", show_header=True)
3197
+ table.add_column("ID", style="cyan")
3198
+ table.add_column("Name", style="green")
3199
+ table.add_column("Data Type", style="yellow")
3200
+ table.add_column("Required", style="magenta")
3201
+ table.add_column("Description", style="white")
3202
+
3203
+ for item in response["value"]:
3204
+ table.add_row(
3205
+ item.get("id", "N/A"),
3206
+ item.get("name", "N/A"),
3207
+ item.get("dataType", "N/A"),
3208
+ "Yes" if item.get("required") else "No",
3209
+ item.get("description", "")[:50] + "..." if len(item.get("description", "")) > 50 else item.get("description", "")
3210
+ )
3211
+ console.print(table)
3212
+ else:
3213
+ console.print("[yellow]No custom attributes found[/yellow]")
3214
+
3215
+
3216
+ @attribute.command(name="get")
3217
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3218
+ @click.option("--output", type=click.Choice(["table", "json"]), default="json", help="Output format")
3219
+ def get_custom_attribute(attribute_id, output):
3220
+ """Get a specific custom attribute definition."""
3221
+ client = UnifiedCatalogClient()
3222
+ args = {"--attribute-id": [attribute_id]}
3223
+ response = client.get_custom_attribute(args)
3224
+
3225
+ if output == "json":
3226
+ _format_json_output(response)
3227
+ else:
3228
+ table = Table(title=f"[bold cyan]Attribute: {response.get('name', 'N/A')}[/bold cyan]")
3229
+ table.add_column("Property", style="cyan")
3230
+ table.add_column("Value", style="white")
3231
+
3232
+ for key, value in response.items():
3233
+ table.add_row(key, str(value))
3234
+ console.print(table)
3235
+
3236
+
3237
+ @attribute.command(name="create")
3238
+ @click.option("--name", required=True, help="Attribute name")
3239
+ @click.option("--data-type", required=True, help="Data type (string, number, boolean, date)")
3240
+ @click.option("--description", default="", help="Attribute description")
3241
+ @click.option("--required", is_flag=True, help="Is this attribute required?")
3242
+ def create_custom_attribute(name, data_type, description, required):
3243
+ """Create a new custom attribute definition."""
3244
+ client = UnifiedCatalogClient()
3245
+ args = {
3246
+ "--name": [name],
3247
+ "--data-type": [data_type],
3248
+ "--description": [description],
3249
+ "--required": ["true" if required else "false"]
3250
+ }
3251
+ response = client.create_custom_attribute(args)
3252
+
3253
+ console.print(f"[green]SUCCESS:[/green] Custom attribute created")
3254
+ _format_json_output(response)
3255
+
3256
+
3257
+ @attribute.command(name="update")
3258
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3259
+ @click.option("--name", help="New attribute name")
3260
+ @click.option("--description", help="New attribute description")
3261
+ @click.option("--required", type=bool, help="Is this attribute required? (true/false)")
3262
+ def update_custom_attribute(attribute_id, name, description, required):
3263
+ """Update an existing custom attribute definition."""
3264
+ client = UnifiedCatalogClient()
3265
+ args = {"--attribute-id": [attribute_id]}
3266
+
3267
+ if name:
3268
+ args["--name"] = [name]
3269
+ if description:
3270
+ args["--description"] = [description]
3271
+ if required is not None:
3272
+ args["--required"] = ["true" if required else "false"]
3273
+
3274
+ response = client.update_custom_attribute(args)
3275
+
3276
+ console.print(f"[green]SUCCESS:[/green] Custom attribute updated")
3277
+ _format_json_output(response)
3278
+
3279
+
3280
+ @attribute.command(name="delete")
3281
+ @click.option("--attribute-id", required=True, help="Attribute ID")
3282
+ @click.confirmation_option(prompt="Are you sure you want to delete this attribute?")
3283
+ def delete_custom_attribute(attribute_id):
3284
+ """Delete a custom attribute definition."""
3285
+ client = UnifiedCatalogClient()
3286
+ args = {"--attribute-id": [attribute_id]}
3287
+ response = client.delete_custom_attribute(args)
3288
+
3289
+ console.print(f"[green]SUCCESS:[/green] Custom attribute '{attribute_id}' deleted")
1970
3290
 
1971
3291
 
1972
3292
  # ========================================