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.
- {pvw_cli-1.14.2/pvw_cli.egg-info → pvw_cli-1.14.4}/PKG-INFO +2 -2
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/README.md +1 -1
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/__init__.py +1 -1
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/collections.py +19 -7
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/unified_catalog.py +13 -5
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_unified_catalog.py +3 -2
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/endpoints.py +2 -1
- {pvw_cli-1.14.2 → pvw_cli-1.14.4/pvw_cli.egg-info}/PKG-INFO +2 -2
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/SOURCES.txt +2 -0
- pvw_cli-1.14.4/tests/test_collections_resources_filter.py +296 -0
- pvw_cli-1.14.4/tests/test_uc_dataproduct_remove_relationship.py +261 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/LICENSE +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/NOTICE +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/__main__.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/__init__.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/account.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/cli.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/console_utils.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/diagnostics.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/domain.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/entity.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/glossary.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/health.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/insight.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/lineage.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/management.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/policystore.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/quality.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/relationship.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/scan.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/search.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/share.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/table_cache.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/types.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/cli/workflow.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/__init__.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_account.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_collections.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_domain.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_entity.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_glossary.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_health.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_insight.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_lineage.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_management.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_policystore.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_quality.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_relationship.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_scan.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_search.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_share.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_types.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/_workflow.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/api_client.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/business_rules.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/client_cache.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/config.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/data_quality.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/endpoint.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/exceptions.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/lineage_visualization.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/monitoring_dashboard.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/query_cache.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/rate_limiter.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/retry_handler.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/scanning_operations.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/settings.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/client/sync_client.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/plugins/__init__.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/purviewcli/plugins/plugin_system.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/dependency_links.txt +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/entry_points.txt +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/not-zip-safe +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/requires.txt +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pvw_cli.egg-info/top_level.txt +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/pyproject.toml +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/setup.cfg +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_copy_data_products.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_copy_entities.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_bulk_read_cli.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_bulk_read_tables_cli.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_business_metadata_cli.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_endpoints.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_entity_list_cli.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_mcp_server.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_mcp_server_basic.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_relationship_bulk_csv_cli.py +0 -0
- {pvw_cli-1.14.2 → pvw_cli-1.14.4}/tests/test_uc_metadata_list_cli.py +0 -0
- {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.
|
|
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
|
-
[](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.
|
|
64
|
+
[](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.4)
|
|
65
65
|
[](https://github.com/Keayoub/pvw-cli)
|
|
66
66
|
[](https://keayoub.github.io/pvw-cli/)
|
|
67
67
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pvw-cli — Microsoft Purview Command-Line Interface
|
|
2
2
|
|
|
3
|
-
[](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.
|
|
3
|
+
[](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.4)
|
|
4
4
|
[](https://github.com/Keayoub/pvw-cli)
|
|
5
5
|
[](https://keayoub.github.io/pvw-cli/)
|
|
6
6
|
|
|
@@ -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
|
-
|
|
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
|
|
828
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
[](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.
|
|
64
|
+
[](https://github.com/Keayoub/pvw-cli/releases/tag/v1.14.4)
|
|
65
65
|
[](https://github.com/Keayoub/pvw-cli)
|
|
66
66
|
[](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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|