pvw-cli 1.14.2__tar.gz → 1.14.4__tar.gz

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 (89) hide show
  1. {pvw_cli-1.14.2/pvw_cli.egg-info → pvw_cli-1.14.4}/PKG-INFO +2 -2
  2. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/README.md +1 -1
  3. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/__init__.py +1 -1
  4. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/collections.py +19 -7
  5. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/unified_catalog.py +13 -5
  6. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_unified_catalog.py +3 -2
  7. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/endpoints.py +2 -1
  8. {pvw_cli-1.14.2 → pvw_cli-1.14.4/pvw_cli.egg-info}/PKG-INFO +2 -2
  9. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/SOURCES.txt +2 -0
  10. pvw_cli-1.14.4/tests/test_collections_resources_filter.py +296 -0
  11. pvw_cli-1.14.4/tests/test_uc_dataproduct_remove_relationship.py +261 -0
  12. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/LICENSE +0 -0
  13. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/NOTICE +0 -0
  14. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/__main__.py +0 -0
  15. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/__init__.py +0 -0
  16. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/account.py +0 -0
  17. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/cli.py +0 -0
  18. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/console_utils.py +0 -0
  19. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/diagnostics.py +0 -0
  20. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/domain.py +0 -0
  21. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/entity.py +0 -0
  22. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/glossary.py +0 -0
  23. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/health.py +0 -0
  24. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/insight.py +0 -0
  25. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/lineage.py +0 -0
  26. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/management.py +0 -0
  27. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/policystore.py +0 -0
  28. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/quality.py +0 -0
  29. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/relationship.py +0 -0
  30. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/scan.py +0 -0
  31. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/search.py +0 -0
  32. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/share.py +0 -0
  33. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/table_cache.py +0 -0
  34. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/types.py +0 -0
  35. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/workflow.py +0 -0
  36. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/__init__.py +0 -0
  37. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_account.py +0 -0
  38. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_collections.py +0 -0
  39. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_domain.py +0 -0
  40. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_entity.py +0 -0
  41. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_glossary.py +0 -0
  42. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_health.py +0 -0
  43. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_insight.py +0 -0
  44. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_lineage.py +0 -0
  45. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_management.py +0 -0
  46. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_policystore.py +0 -0
  47. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_quality.py +0 -0
  48. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_relationship.py +0 -0
  49. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_scan.py +0 -0
  50. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_search.py +0 -0
  51. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_share.py +0 -0
  52. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_types.py +0 -0
  53. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_workflow.py +0 -0
  54. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/api_client.py +0 -0
  55. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/business_rules.py +0 -0
  56. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/client_cache.py +0 -0
  57. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/config.py +0 -0
  58. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/data_quality.py +0 -0
  59. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/endpoint.py +0 -0
  60. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/exceptions.py +0 -0
  61. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/lineage_visualization.py +0 -0
  62. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/monitoring_dashboard.py +0 -0
  63. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/query_cache.py +0 -0
  64. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/rate_limiter.py +0 -0
  65. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/retry_handler.py +0 -0
  66. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/scanning_operations.py +0 -0
  67. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/settings.py +0 -0
  68. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/sync_client.py +0 -0
  69. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/plugins/__init__.py +0 -0
  70. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/plugins/plugin_system.py +0 -0
  71. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/dependency_links.txt +0 -0
  72. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/entry_points.txt +0 -0
  73. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/not-zip-safe +0 -0
  74. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/requires.txt +0 -0
  75. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/top_level.txt +0 -0
  76. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pyproject.toml +0 -0
  77. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/setup.cfg +0 -0
  78. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_copy_data_products.py +0 -0
  79. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_copy_entities.py +0 -0
  80. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_bulk_read_cli.py +0 -0
  81. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_bulk_read_tables_cli.py +0 -0
  82. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_business_metadata_cli.py +0 -0
  83. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_endpoints.py +0 -0
  84. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_list_cli.py +0 -0
  85. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_mcp_server.py +0 -0
  86. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_mcp_server_basic.py +0 -0
  87. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_relationship_bulk_csv_cli.py +0 -0
  88. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_uc_metadata_list_cli.py +0 -0
  89. {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_unified_catalog_term_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pvw-cli
3
- Version: 1.14.2
3
+ Version: 1.14.4
4
4
  Summary: Microsoft Purview CLI with comprehensive automation capabilities
5
5
  Author-email: AYOUB KEBAILI <keayoub@msn.com>
6
6
  Maintainer-email: AYOUB KEBAILI <keayoub@msn.com>
@@ -61,7 +61,7 @@ Dynamic: license-file
61
61
 
62
62
  # pvw-cli — Microsoft Purview Command-Line Interface
63
63
 
64
- [![Version](https://img.shields.io/badge/version-1.11.18-blue.svg)](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.2)
64
+ [![Version](https://img.shields.io/badge/version-1.11.18-blue.svg)](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.4)
65
65
  [![Status](https://img.shields.io/badge/status-stable-success.svg)](https://github.com/Keayoub/pvw-cli)
66
66
  [![Docs](https://img.shields.io/badge/docs-github%20pages-0A8F84?logo=githubpages&logoColor=white)](https://keayoub.github.io/pvw-cli/)
67
67
 
@@ -1,6 +1,6 @@
1
1
  # pvw-cli — Microsoft Purview Command-Line Interface
2
2
 
3
- [![Version](https://img.shields.io/badge/version-1.11.18-blue.svg)](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.2)
3
+ [![Version](https://img.shields.io/badge/version-1.11.18-blue.svg)](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.4)
4
4
  [![Status](https://img.shields.io/badge/status-stable-success.svg)](https://github.com/Keayoub/pvw-cli)
5
5
  [![Docs](https://img.shields.io/badge/docs-github%20pages-0A8F84?logo=githubpages&logoColor=white)](https://keayoub.github.io/pvw-cli/)
6
6
 
@@ -1,6 +1,6 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
 
3
- __version__ = "1.14.2"
3
+ __version__ = "1.14.4"
4
4
 
5
5
  # Import main client modules
6
6
  from .client import *
@@ -811,11 +811,17 @@ def resources(collection_name, format, output_json, sort_by, asset_type, data_so
811
811
  return
812
812
 
813
813
  # Build collection map and filter if needed
814
+ # --collection-name matches against technical name OR friendly name
814
815
  target_collections = {}
815
816
  for coll in collections_list:
816
817
  coll_name = coll.get('name', 'Unknown')
817
- if collection_name is None or coll_name == collection_name:
818
+ friendly_name = coll.get('friendlyName', '')
819
+ if collection_name is None or coll_name == collection_name or friendly_name == collection_name:
818
820
  target_collections[coll_name] = coll
821
+
822
+ if collection_name is not None and not target_collections:
823
+ click.echo(f"[WARN] No collection found matching '{collection_name}' (checked technical name and friendly name)", err=True)
824
+ return
819
825
 
820
826
  # Fetch assets for each target collection
821
827
  collections_assets = {}
@@ -824,13 +830,19 @@ def resources(collection_name, format, output_json, sort_by, asset_type, data_so
824
830
  click.echo(f"[FETCH] Fetching assets from '{coll_name}'...", err=True)
825
831
 
826
832
  try:
827
- # Build API filter with collectionId and optional entityType
828
- filter_dict = {"collectionId": coll_name}
829
-
830
- # Add entityType filter to API request if specified
833
+ # Build API filter using the compound AND syntax required by Purview Search API.
834
+ # A flat dict like {"collectionId": "x", "entityType": "y"} is NOT valid;
835
+ # multiple conditions must be wrapped in an {"and": [...]} array.
831
836
  if asset_type:
832
- filter_dict["entityType"] = asset_type
833
-
837
+ filter_dict = {
838
+ "and": [
839
+ {"collectionId": coll_name},
840
+ {"entityType": asset_type}
841
+ ]
842
+ }
843
+ else:
844
+ filter_dict = {"collectionId": coll_name}
845
+
834
846
  # Fetch up to 1000 assets per collection (API limit)
835
847
  search_args = {
836
848
  '--filter': json.dumps(filter_dict),
@@ -652,13 +652,21 @@ def remove_relationship(product_id, entity_type, entity_id, confirm):
652
652
 
653
653
  result = client.delete_data_product_relationship(args)
654
654
 
655
- # DELETE returns 204 No Content on success
656
- if result is None or (isinstance(result, dict) and not result.get("error")):
655
+ # DELETE returns 204 No Content on success.
656
+ # Error responses carry {"status": "error", "message": "..."} NOT an "error" key —
657
+ # so we must check result.get("status") to avoid treating every error as SUCCESS.
658
+ is_error = isinstance(result, dict) and (
659
+ result.get("status") == "error"
660
+ or "error" in result
661
+ )
662
+ if result is None or not is_error:
657
663
  console.print(f"[green]SUCCESS:[/green] Deleted relationship to {entity_type} '{entity_id}'")
658
- elif isinstance(result, dict) and "error" in result:
659
- console.print(f"[red]ERROR:[/red] {result.get('error', 'Unknown error')}")
660
664
  else:
661
- console.print(f"[green]SUCCESS:[/green] Deleted relationship")
665
+ error_msg = result.get("message") or result.get("error") or "Unknown error"
666
+ status_code = result.get("status_code", "")
667
+ detail = f" (HTTP {status_code})" if status_code else ""
668
+ console.print(f"[red]ERROR:[/red] {error_msg}{detail}")
669
+ console.print("[dim]Tip: set env var PURVIEWCLI_DEBUG=1 to see the full request/response.[/dim]")
662
670
 
663
671
  except Exception as e:
664
672
  console.print(f"[red]ERROR:[/red] {str(e)}")
@@ -1148,12 +1148,13 @@ Use Cases:
1148
1148
  entity_id = args.get("--entity-id", [""])[0]
1149
1149
 
1150
1150
  self.method = "DELETE"
1151
+ # entityId is part of the URL path; entityType is the only query param
1151
1152
  self.endpoint = ENDPOINTS["unified_catalog"]["delete_data_product_relationship"].format(
1152
- productId=product_id
1153
+ productId=product_id,
1154
+ entityId=entity_id,
1153
1155
  )
1154
1156
  self.params = {
1155
1157
  "entityType": entity_type.upper(),
1156
- "entityId": entity_id
1157
1158
  }
1158
1159
 
1159
1160
  @decorator
@@ -459,7 +459,8 @@ ENDPOINTS = {
459
459
  # Data product relationships
460
460
  "create_data_product_relationship": "/datagovernance/catalog/dataproducts/{productId}/relationships",
461
461
  "list_data_product_relationships": "/datagovernance/catalog/dataproducts/{productId}/relationships",
462
- "delete_data_product_relationship": "/datagovernance/catalog/dataproducts/{productId}/relationships",
462
+ # entityId must be in the URL path (not a query param) for the DELETE to hit the correct resource
463
+ "delete_data_product_relationship": "/datagovernance/catalog/dataproducts/{productId}/relationships/{entityId}",
463
464
  # Data product query
464
465
  "query_data_products": "/datagovernance/catalog/dataproducts/query",
465
466
  # Terms (UC specific)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pvw-cli
3
- Version: 1.14.2
3
+ Version: 1.14.4
4
4
  Summary: Microsoft Purview CLI with comprehensive automation capabilities
5
5
  Author-email: AYOUB KEBAILI <keayoub@msn.com>
6
6
  Maintainer-email: AYOUB KEBAILI <keayoub@msn.com>
@@ -61,7 +61,7 @@ Dynamic: license-file
61
61
 
62
62
  # pvw-cli — Microsoft Purview Command-Line Interface
63
63
 
64
- [![Version](https://img.shields.io/badge/version-1.11.18-blue.svg)](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.2)
64
+ [![Version](https://img.shields.io/badge/version-1.11.18-blue.svg)](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.4)
65
65
  [![Status](https://img.shields.io/badge/status-stable-success.svg)](https://github.com/Keayoub/pvw-cli)
66
66
  [![Docs](https://img.shields.io/badge/docs-github%20pages-0A8F84?logo=githubpages&logoColor=white)](https://keayoub.github.io/pvw-cli/)
67
67
 
@@ -71,6 +71,7 @@ pvw_cli.egg-info/entry_points.txt
71
71
  pvw_cli.egg-info/not-zip-safe
72
72
  pvw_cli.egg-info/requires.txt
73
73
  pvw_cli.egg-info/top_level.txt
74
+ tests/test_collections_resources_filter.py
74
75
  tests/test_copy_data_products.py
75
76
  tests/test_copy_entities.py
76
77
  tests/test_entity_bulk_read_cli.py
@@ -81,5 +82,6 @@ tests/test_entity_list_cli.py
81
82
  tests/test_mcp_server.py
82
83
  tests/test_mcp_server_basic.py
83
84
  tests/test_relationship_bulk_csv_cli.py
85
+ tests/test_uc_dataproduct_remove_relationship.py
84
86
  tests/test_uc_metadata_list_cli.py
85
87
  tests/test_unified_catalog_term_update.py
@@ -0,0 +1,296 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Tests for the two bugs fixed in `pvw collections resources`:
4
+
5
+ 1. --asset-type now sends an {"and": [...]} compound filter to the Search API
6
+ instead of a flat dict that caused assets from the wrong collection to be returned.
7
+
8
+ 2. --collection-name now resolves against the collection's `friendlyName` field
9
+ in addition to its technical `name` field.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ from unittest.mock import MagicMock, call, patch
16
+
17
+ import pytest
18
+ from click.testing import CliRunner
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+
22
+ from purviewcli.cli.cli import main
23
+
24
+ RUNNER = CliRunner()
25
+
26
+
27
+ def invoke(*args, **kwargs):
28
+ return RUNNER.invoke(main, list(args), catch_exceptions=False, **kwargs)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Shared fixtures
33
+ # ---------------------------------------------------------------------------
34
+
35
+ COLLECTIONS_RESPONSE = {
36
+ "value": [
37
+ {"name": "col1", "friendlyName": "My First Collection"},
38
+ {"name": "col2", "friendlyName": "My Second Collection"},
39
+ ]
40
+ }
41
+
42
+ ASSET_FABRIC_TABLE = {
43
+ "id": "aaaa-1111",
44
+ "name": "Drivers",
45
+ "entityType": "fabric_lakehouse_table",
46
+ "assetType": ["Fabric"],
47
+ "qualifiedName": "https://app.fabric.microsoft.com/groups/xxx/Drivers",
48
+ }
49
+
50
+ ASSET_POWERBI = {
51
+ "id": "bbbb-2222",
52
+ "name": "peakon_verification",
53
+ "entityType": "powerbi_report",
54
+ "assetType": ["Power BI"],
55
+ "qualifiedName": "Power BI/peakon_verification",
56
+ }
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Bug 1: compound AND filter for --asset-type
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class TestAssetTypeFilterUsesAndSyntax:
65
+ """The Search API requires {"and": [...]} for compound filters.
66
+ Sending a flat dict {"collectionId": "x", "entityType": "y"} caused the
67
+ collectionId restriction to be ignored, returning assets from all collections.
68
+ """
69
+
70
+ @patch("purviewcli.cli.collections.Collections")
71
+ @patch("purviewcli.client._search.Search")
72
+ def test_no_asset_type_sends_plain_collectionid_filter(
73
+ self, mock_search_cls, mock_collections_cls
74
+ ):
75
+ """Without --asset-type the filter stays a simple {"collectionId": "..."} dict."""
76
+ mock_collections = MagicMock()
77
+ mock_collections_cls.return_value = mock_collections
78
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
79
+
80
+ mock_search = MagicMock()
81
+ mock_search_cls.return_value = mock_search
82
+ mock_search.searchQuery.return_value = {"value": [ASSET_FABRIC_TABLE]}
83
+
84
+ result = invoke("collections", "resources", "--collection-name", "col1", "--format", "json")
85
+
86
+ assert result.exit_code == 0, result.output
87
+ mock_search.searchQuery.assert_called_once()
88
+ call_args = mock_search.searchQuery.call_args[0][0]
89
+ sent_filter = json.loads(call_args["--filter"])
90
+
91
+ # Must be a plain dict, not wrapped in "and"
92
+ assert "and" not in sent_filter
93
+ assert sent_filter.get("collectionId") == "col1"
94
+
95
+ @patch("purviewcli.cli.collections.Collections")
96
+ @patch("purviewcli.client._search.Search")
97
+ def test_with_asset_type_sends_and_compound_filter(
98
+ self, mock_search_cls, mock_collections_cls
99
+ ):
100
+ """With --asset-type the filter must use {"and": [{collectionId}, {entityType}]}."""
101
+ mock_collections = MagicMock()
102
+ mock_collections_cls.return_value = mock_collections
103
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
104
+
105
+ mock_search = MagicMock()
106
+ mock_search_cls.return_value = mock_search
107
+ mock_search.searchQuery.return_value = {"value": [ASSET_FABRIC_TABLE]}
108
+
109
+ result = invoke(
110
+ "collections",
111
+ "resources",
112
+ "--collection-name",
113
+ "col1",
114
+ "--asset-type",
115
+ "fabric_lakehouse_table",
116
+ "--format",
117
+ "json",
118
+ )
119
+
120
+ assert result.exit_code == 0, result.output
121
+ mock_search.searchQuery.assert_called_once()
122
+ call_args = mock_search.searchQuery.call_args[0][0]
123
+ sent_filter = json.loads(call_args["--filter"])
124
+
125
+ # MUST use the compound AND syntax
126
+ assert "and" in sent_filter, (
127
+ "Expected {'and': [...]} compound filter but got: " + json.dumps(sent_filter)
128
+ )
129
+ conditions = sent_filter["and"]
130
+ assert isinstance(conditions, list) and len(conditions) == 2
131
+
132
+ coll_condition = next((c for c in conditions if "collectionId" in c), None)
133
+ type_condition = next((c for c in conditions if "entityType" in c), None)
134
+
135
+ assert coll_condition is not None, "Missing collectionId condition"
136
+ assert type_condition is not None, "Missing entityType condition"
137
+ assert coll_condition["collectionId"] == "col1"
138
+ assert type_condition["entityType"] == "fabric_lakehouse_table"
139
+
140
+ @patch("purviewcli.cli.collections.Collections")
141
+ @patch("purviewcli.client._search.Search")
142
+ def test_flat_filter_is_NOT_sent_when_asset_type_given(
143
+ self, mock_search_cls, mock_collections_cls
144
+ ):
145
+ """Regression: the old broken form {"collectionId": x, "entityType": y} must not appear."""
146
+ mock_collections = MagicMock()
147
+ mock_collections_cls.return_value = mock_collections
148
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
149
+
150
+ mock_search = MagicMock()
151
+ mock_search_cls.return_value = mock_search
152
+ mock_search.searchQuery.return_value = {"value": []}
153
+
154
+ invoke(
155
+ "collections",
156
+ "resources",
157
+ "--collection-name",
158
+ "col1",
159
+ "--asset-type",
160
+ "fabric_lakehouse_table",
161
+ )
162
+
163
+ call_args = mock_search.searchQuery.call_args[0][0]
164
+ sent_filter = json.loads(call_args["--filter"])
165
+
166
+ # The old broken flat form must NOT appear
167
+ assert not (
168
+ "collectionId" in sent_filter and "entityType" in sent_filter
169
+ ), "Found old flat filter — collectionId+entityType must not be top-level siblings"
170
+
171
+ @patch("purviewcli.cli.collections.Collections")
172
+ @patch("purviewcli.client._search.Search")
173
+ def test_asset_type_filter_only_fetches_matching_collection(
174
+ self, mock_search_cls, mock_collections_cls
175
+ ):
176
+ """Only the targeted collection should be queried when --collection-name is given."""
177
+ mock_collections = MagicMock()
178
+ mock_collections_cls.return_value = mock_collections
179
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
180
+
181
+ mock_search = MagicMock()
182
+ mock_search_cls.return_value = mock_search
183
+ mock_search.searchQuery.return_value = {"value": [ASSET_FABRIC_TABLE]}
184
+
185
+ invoke(
186
+ "collections",
187
+ "resources",
188
+ "--collection-name",
189
+ "col1",
190
+ "--asset-type",
191
+ "fabric_lakehouse_table",
192
+ )
193
+
194
+ # searchQuery called exactly once — only for col1, not col2
195
+ assert mock_search.searchQuery.call_count == 1
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Bug 2: --collection-name resolves friendly name
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ class TestCollectionNameFriendlyNameResolution:
204
+ """--collection-name should match either the technical 'name' or 'friendlyName'."""
205
+
206
+ @patch("purviewcli.cli.collections.Collections")
207
+ @patch("purviewcli.client._search.Search")
208
+ def test_technical_name_still_works(self, mock_search_cls, mock_collections_cls):
209
+ """Existing behaviour: match by technical short-id is preserved."""
210
+ mock_collections = MagicMock()
211
+ mock_collections_cls.return_value = mock_collections
212
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
213
+
214
+ mock_search = MagicMock()
215
+ mock_search_cls.return_value = mock_search
216
+ mock_search.searchQuery.return_value = {"value": [ASSET_FABRIC_TABLE]}
217
+
218
+ result = invoke("collections", "resources", "--collection-name", "col2", "--format", "json")
219
+
220
+ assert result.exit_code == 0, result.output
221
+ assert mock_search.searchQuery.call_count == 1
222
+ call_args = mock_search.searchQuery.call_args[0][0]
223
+ sent_filter = json.loads(call_args["--filter"])
224
+ assert sent_filter.get("collectionId") == "col2"
225
+
226
+ @patch("purviewcli.cli.collections.Collections")
227
+ @patch("purviewcli.client._search.Search")
228
+ def test_friendly_name_is_resolved_to_technical_name(
229
+ self, mock_search_cls, mock_collections_cls
230
+ ):
231
+ """New behaviour: passing the friendly name must find and query the correct collection."""
232
+ mock_collections = MagicMock()
233
+ mock_collections_cls.return_value = mock_collections
234
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
235
+
236
+ mock_search = MagicMock()
237
+ mock_search_cls.return_value = mock_search
238
+ mock_search.searchQuery.return_value = {"value": [ASSET_POWERBI]}
239
+
240
+ result = invoke(
241
+ "collections",
242
+ "resources",
243
+ "--collection-name",
244
+ "My Second Collection", # friendly name, not technical "col2"
245
+ "--format",
246
+ "json",
247
+ )
248
+
249
+ assert result.exit_code == 0, result.output
250
+ # Must have queried exactly one collection
251
+ assert mock_search.searchQuery.call_count == 1
252
+ call_args = mock_search.searchQuery.call_args[0][0]
253
+ sent_filter = json.loads(call_args["--filter"])
254
+ # And the filter must use the TECHNICAL name "col2", not the friendly name
255
+ assert sent_filter.get("collectionId") == "col2", (
256
+ f"Expected collectionId='col2' but got '{sent_filter.get('collectionId')}'"
257
+ )
258
+
259
+ @patch("purviewcli.cli.collections.Collections")
260
+ @patch("purviewcli.client._search.Search")
261
+ def test_unknown_name_prints_warning_and_returns(
262
+ self, mock_search_cls, mock_collections_cls
263
+ ):
264
+ """An unrecognised collection name must emit a warning and NOT call the Search API."""
265
+ mock_collections = MagicMock()
266
+ mock_collections_cls.return_value = mock_collections
267
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
268
+
269
+ mock_search = MagicMock()
270
+ mock_search_cls.return_value = mock_search
271
+
272
+ result = invoke(
273
+ "collections", "resources", "--collection-name", "does-not-exist"
274
+ )
275
+
276
+ assert result.exit_code == 0, result.output
277
+ mock_search.searchQuery.assert_not_called()
278
+
279
+ @patch("purviewcli.cli.collections.Collections")
280
+ @patch("purviewcli.client._search.Search")
281
+ def test_no_collection_name_queries_all_collections(
282
+ self, mock_search_cls, mock_collections_cls
283
+ ):
284
+ """Without --collection-name every collection in the account is queried."""
285
+ mock_collections = MagicMock()
286
+ mock_collections_cls.return_value = mock_collections
287
+ mock_collections.collectionsRead.return_value = COLLECTIONS_RESPONSE
288
+
289
+ mock_search = MagicMock()
290
+ mock_search_cls.return_value = mock_search
291
+ mock_search.searchQuery.return_value = {"value": []}
292
+
293
+ invoke("collections", "resources")
294
+
295
+ # Two collections in the fixture -> two Search API calls
296
+ assert mock_search.searchQuery.call_count == 2
@@ -0,0 +1,261 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ Tests for the two bugs fixed in `pvw uc dataproduct remove-relationship`:
4
+
5
+ 1. The DELETE endpoint now has {entityId} in the URL path
6
+ (previously it was sent only as a query param, which the Purview API ignores for DELETE).
7
+
8
+ 2. API errors are now surfaced to the user correctly.
9
+ Previously, any response without a literal "error" key was printed as SUCCESS,
10
+ masking real HTTP errors (404, 403, 400, ...).
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ from unittest.mock import MagicMock, patch
16
+
17
+ import pytest
18
+ from click.testing import CliRunner
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+
22
+ from purviewcli.cli.cli import main
23
+ from purviewcli.client._unified_catalog import UnifiedCatalogClient
24
+ from purviewcli.client.endpoints import ENDPOINTS
25
+
26
+ RUNNER = CliRunner()
27
+
28
+
29
+ def invoke(*args, **kwargs):
30
+ return RUNNER.invoke(main, list(args), catch_exceptions=False, **kwargs)
31
+
32
+
33
+ PRODUCT_ID = "prod-aaaa-1111"
34
+ ENTITY_ID = "asset-bbbb-2222"
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Bug 1: endpoint shape — entityId in URL path
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class TestDeleteRelationshipEndpoint:
43
+ """The entityId must appear in the URL path, not only as a query param."""
44
+
45
+ @patch("purviewcli.client.endpoint.get_data")
46
+ def test_entity_id_in_url_path(self, mock_get_data):
47
+ """Verify the endpoint string contains the entityId as a path segment."""
48
+ mock_get_data.return_value = {} # simulate 204 No Content
49
+
50
+ client = UnifiedCatalogClient()
51
+ client.delete_data_product_relationship(
52
+ {
53
+ "--product-id": [PRODUCT_ID],
54
+ "--entity-type": ["DATAASSET"],
55
+ "--entity-id": [ENTITY_ID],
56
+ }
57
+ )
58
+
59
+ call_args = mock_get_data.call_args[0][0]
60
+ endpoint = call_args["endpoint"]
61
+
62
+ assert ENTITY_ID in endpoint, (
63
+ f"entityId '{ENTITY_ID}' should appear as a path segment in the endpoint, "
64
+ f"but got: '{endpoint}'"
65
+ )
66
+ assert endpoint.endswith(f"/relationships/{ENTITY_ID}"), (
67
+ f"Endpoint must end with /relationships/{{entityId}}, got: '{endpoint}'"
68
+ )
69
+
70
+ @patch("purviewcli.client.endpoint.get_data")
71
+ def test_entity_id_NOT_in_query_params(self, mock_get_data):
72
+ """Regression: entityId must NOT be sent as a query parameter."""
73
+ mock_get_data.return_value = {}
74
+
75
+ client = UnifiedCatalogClient()
76
+ client.delete_data_product_relationship(
77
+ {
78
+ "--product-id": [PRODUCT_ID],
79
+ "--entity-type": ["DATAASSET"],
80
+ "--entity-id": [ENTITY_ID],
81
+ }
82
+ )
83
+
84
+ call_args = mock_get_data.call_args[0][0]
85
+ params = call_args.get("params", {}) or {}
86
+
87
+ assert "entityId" not in params, (
88
+ f"entityId must NOT be a query param (it belongs in the path), "
89
+ f"but found params={params}"
90
+ )
91
+
92
+ @patch("purviewcli.client.endpoint.get_data")
93
+ def test_entity_type_sent_as_query_param(self, mock_get_data):
94
+ """entityType must still be sent as a query parameter."""
95
+ mock_get_data.return_value = {}
96
+
97
+ client = UnifiedCatalogClient()
98
+ client.delete_data_product_relationship(
99
+ {
100
+ "--product-id": [PRODUCT_ID],
101
+ "--entity-type": ["DATAASSET"],
102
+ "--entity-id": [ENTITY_ID],
103
+ }
104
+ )
105
+
106
+ call_args = mock_get_data.call_args[0][0]
107
+ params = call_args.get("params", {}) or {}
108
+
109
+ assert params.get("entityType") == "DATAASSET", (
110
+ f"entityType should be uppercase query param 'DATAASSET', got params={params}"
111
+ )
112
+
113
+ @patch("purviewcli.client.endpoint.get_data")
114
+ def test_http_method_is_delete(self, mock_get_data):
115
+ mock_get_data.return_value = {}
116
+
117
+ client = UnifiedCatalogClient()
118
+ client.delete_data_product_relationship(
119
+ {
120
+ "--product-id": [PRODUCT_ID],
121
+ "--entity-type": ["TERM"],
122
+ "--entity-id": [ENTITY_ID],
123
+ }
124
+ )
125
+
126
+ call_args = mock_get_data.call_args[0][0]
127
+ assert call_args["method"] == "DELETE"
128
+
129
+ def test_endpoint_template_contains_entity_id_placeholder(self):
130
+ """Sanity-check the endpoint definition in endpoints.py."""
131
+ template = ENDPOINTS["unified_catalog"]["delete_data_product_relationship"]
132
+ assert "{entityId}" in template, (
133
+ f"Endpoint template must contain {{entityId}} placeholder, got: '{template}'"
134
+ )
135
+ assert "{productId}" in template
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Bug 2: error detection — false SUCCESS masking real API errors
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ class TestRemoveRelationshipErrorHandling:
144
+ """The CLI must surface API errors; previously any response without a literal
145
+ 'error' key was printed as SUCCESS."""
146
+
147
+ @patch("purviewcli.cli.unified_catalog.UnifiedCatalogClient")
148
+ def test_204_success_prints_success(self, mock_client_cls):
149
+ """A real 204 No Content (empty dict) must still show SUCCESS."""
150
+ mock_client = MagicMock()
151
+ mock_client_cls.return_value = mock_client
152
+ mock_client.delete_data_product_relationship.return_value = {} # 204 → {}
153
+
154
+ result = invoke(
155
+ "uc", "dataproduct", "remove-relationship",
156
+ "--product-id", PRODUCT_ID,
157
+ "--entity-type", "DATAASSET",
158
+ "--entity-id", ENTITY_ID,
159
+ "--no-confirm",
160
+ )
161
+
162
+ assert result.exit_code == 0, result.output
163
+ assert "SUCCESS" in result.output
164
+ assert "ERROR" not in result.output
165
+
166
+ @patch("purviewcli.cli.unified_catalog.UnifiedCatalogClient")
167
+ def test_404_error_prints_error_not_success(self, mock_client_cls):
168
+ """A 404 from the API must display ERROR, not SUCCESS.
169
+
170
+ This is the regression test for the false-SUCCESS bug:
171
+ the old code checked result.get('error') which is None for
172
+ {'status': 'error', 'message': '...', 'status_code': 404},
173
+ causing it to incorrectly print SUCCESS.
174
+ """
175
+ mock_client = MagicMock()
176
+ mock_client_cls.return_value = mock_client
177
+ mock_client.delete_data_product_relationship.return_value = {
178
+ "status": "error",
179
+ "message": "HTTP 404: Relationship not found",
180
+ "status_code": 404,
181
+ }
182
+
183
+ result = invoke(
184
+ "uc", "dataproduct", "remove-relationship",
185
+ "--product-id", PRODUCT_ID,
186
+ "--entity-type", "DATAASSET",
187
+ "--entity-id", ENTITY_ID,
188
+ "--no-confirm",
189
+ )
190
+
191
+ assert result.exit_code == 0, result.output
192
+ assert "ERROR" in result.output, (
193
+ "Expected ERROR in output for a 404 response, got: " + result.output
194
+ )
195
+ assert "SUCCESS" not in result.output, (
196
+ "Got SUCCESS for a 404 — false-SUCCESS bug still present: " + result.output
197
+ )
198
+
199
+ @patch("purviewcli.cli.unified_catalog.UnifiedCatalogClient")
200
+ def test_403_error_includes_message_in_output(self, mock_client_cls):
201
+ """The actual API error message must appear in the output."""
202
+ mock_client = MagicMock()
203
+ mock_client_cls.return_value = mock_client
204
+ mock_client.delete_data_product_relationship.return_value = {
205
+ "status": "error",
206
+ "message": "HTTP 403: Insufficient permissions",
207
+ "status_code": 403,
208
+ }
209
+
210
+ result = invoke(
211
+ "uc", "dataproduct", "remove-relationship",
212
+ "--product-id", PRODUCT_ID,
213
+ "--entity-type", "DATAASSET",
214
+ "--entity-id", ENTITY_ID,
215
+ "--no-confirm",
216
+ )
217
+
218
+ assert "403" in result.output or "Insufficient permissions" in result.output, (
219
+ "Expected error details in output, got: " + result.output
220
+ )
221
+
222
+ @patch("purviewcli.cli.unified_catalog.UnifiedCatalogClient")
223
+ def test_none_return_treated_as_success(self, mock_client_cls):
224
+ """None is a valid no-op success return (some DELETE paths return None)."""
225
+ mock_client = MagicMock()
226
+ mock_client_cls.return_value = mock_client
227
+ mock_client.delete_data_product_relationship.return_value = None
228
+
229
+ result = invoke(
230
+ "uc", "dataproduct", "remove-relationship",
231
+ "--product-id", PRODUCT_ID,
232
+ "--entity-type", "DATAASSET",
233
+ "--entity-id", ENTITY_ID,
234
+ "--no-confirm",
235
+ )
236
+
237
+ assert result.exit_code == 0, result.output
238
+ assert "SUCCESS" in result.output
239
+
240
+ @patch("purviewcli.cli.unified_catalog.UnifiedCatalogClient")
241
+ def test_error_output_includes_debug_tip(self, mock_client_cls):
242
+ """Errors should hint that PURVIEWCLI_DEBUG=1 can help diagnose the issue."""
243
+ mock_client = MagicMock()
244
+ mock_client_cls.return_value = mock_client
245
+ mock_client.delete_data_product_relationship.return_value = {
246
+ "status": "error",
247
+ "message": "HTTP 500: Internal server error",
248
+ "status_code": 500,
249
+ }
250
+
251
+ result = invoke(
252
+ "uc", "dataproduct", "remove-relationship",
253
+ "--product-id", PRODUCT_ID,
254
+ "--entity-type", "DATAASSET",
255
+ "--entity-id", ENTITY_ID,
256
+ "--no-confirm",
257
+ )
258
+
259
+ assert "PURVIEWCLI_DEBUG" in result.output, (
260
+ "Expected PURVIEWCLI_DEBUG hint in error output, got: " + result.output
261
+ )
File without changes
File without changes
File without changes
File without changes
File without changes