diff --git a/pyegeria/omvs/asset_maker.py b/pyegeria/omvs/asset_maker.py index 2d71cf2c..6da398f9 100644 --- a/pyegeria/omvs/asset_maker.py +++ b/pyegeria/omvs/asset_maker.py @@ -2480,6 +2480,118 @@ def detach_data_set_content( self._async_detach_data_set_content(data_set_guid, data_content_asset_guid, body) ) + # DataSetContent is a multi-link relationship: the same data set and content may be linked more than once + # (for example, once for each information supply chain). The two methods below therefore address ONE + # relationship by its own unique identifier, rather than by the two linked elements. + + @dynamic_catch + async def _async_update_data_set_content( + self, + data_set_content_guid: str, + body: dict | UpdateRelationshipRequestBody | None = None, + ) -> None: + """Update the properties of one DataSetContent relationship. Async version. + + Parameters + ---------- + data_set_content_guid: str + Unique identifier of the DataSetContent relationship (not of the data set or the content). + body: dict | UpdateRelationshipRequestBody, optional + The properties to update. + + Returns + ------- + None + + Raises + ------ + PyegeriaException + One of the pyegeria exceptions will be raised if there are issues in communications, message format, or + Egeria errors. + + Notes + ----- + Sample body: + { + "class" : "UpdateRelationshipRequestBody", + "mergeUpdate": true, + "properties": { + "class": "DataSetContentProperties", + "queryId": "add id that is used on overall formula", + "query": "add query", + "queryType": "add query type", + "iscQualifiedName": "add qualified name" + } + } + """ + url = f"{self.asset_command_root}/data-set-content/{data_set_content_guid}/update" + await self._async_update_relationship_request(url, ["DataSetContentProperties"], body) + + @dynamic_catch + def update_data_set_content( + self, + data_set_content_guid: str, + body: dict | UpdateRelationshipRequestBody | None = None, + ) -> None: + """Update the properties of one DataSetContent relationship. + + Parameters + ---------- + data_set_content_guid: str + Unique identifier of the DataSetContent relationship (not of the data set or the content). + body: dict | UpdateRelationshipRequestBody, optional + The properties to update. + """ + loop = asyncio.get_event_loop() + loop.run_until_complete(self._async_update_data_set_content(data_set_content_guid, body)) + + @dynamic_catch + async def _async_detach_data_set_content_relationship( + self, + data_set_content_guid: str, + body: dict | DeleteRelationshipRequestBody | None = None, + ) -> None: + """Remove one DataSetContent relationship, leaving any others between the same elements in place. + Async version. + + Parameters + ---------- + data_set_content_guid: str + Unique identifier of the DataSetContent relationship (not of the data set or the content). + body: dict | DeleteRelationshipRequestBody, optional + Additional parameters for the delete operation. + + Returns + ------- + None + + Raises + ------ + PyegeriaException + One of the pyegeria exceptions will be raised if there are issues in communications, message format, or + Egeria errors. + """ + url = f"{self.asset_command_root}/data-set-content/{data_set_content_guid}/detach" + await self._async_delete_relationship_request(url, body) + + @dynamic_catch + def detach_data_set_content_relationship( + self, + data_set_content_guid: str, + body: dict | DeleteRelationshipRequestBody | None = None, + ) -> None: + """Remove one DataSetContent relationship, leaving any others between the same elements in place. + + Parameters + ---------- + data_set_content_guid: str + Unique identifier of the DataSetContent relationship (not of the data set or the content). + body: dict | DeleteRelationshipRequestBody, optional + Additional parameters for the delete operation. + """ + loop = asyncio.get_event_loop() + loop.run_until_complete(self._async_detach_data_set_content_relationship(data_set_content_guid, body)) + # # Report Methods # diff --git a/pyegeria/omvs/connection_maker.py b/pyegeria/omvs/connection_maker.py index 49edd403..ff7d6de1 100644 --- a/pyegeria/omvs/connection_maker.py +++ b/pyegeria/omvs/connection_maker.py @@ -7,6 +7,7 @@ """ from pyegeria.core._server_client import ServerClient +from pyegeria.core.utils import dynamic_catch from pyegeria.models.models import ( NewElementRequestBody, UpdateElementRequestBody, @@ -1011,6 +1012,46 @@ def detach_asset_from_connection(self, asset_guid: str, connection_guid: str, bo loop = asyncio.get_event_loop() return loop.run_until_complete(self._async_detach_asset_from_connection(asset_guid, connection_guid, body)) + # Generalisation of link_asset_to_connection / detach_asset_from_connection: any element that has a digital + # resource (not only an asset) can be attached to the connection that accesses that resource. + + @dynamic_catch + async def _async_link_resource_to_connection(self, element_guid: str, connection_guid: str, body: Union[NewRelationshipRequestBody, dict] = None): + """Attach an element to the connection that accesses its digital resource. Async version. + + Sample body: + { + "class" : "NewRelationshipRequestBody", + "properties": { + "class": "ResourceConnectionProperties", + "label": "add label here", + "description": "add description here", + "effectiveFrom": "2024-01-01T00:00:00.000+00:00", + "effectiveTo": "2024-12-31T23:59:59.999+00:00" + } + } + """ + url = f"{self.base_url}/elements/{element_guid}/connections/{connection_guid}/attach" + await self._async_new_relationship_request(url, ["ResourceConnectionProperties"], body) + + @dynamic_catch + def link_resource_to_connection(self, element_guid: str, connection_guid: str, body: Union[NewRelationshipRequestBody, dict] = None): + """Attach an element to the connection that accesses its digital resource.""" + loop = asyncio.get_event_loop() + return loop.run_until_complete(self._async_link_resource_to_connection(element_guid, connection_guid, body)) + + @dynamic_catch + async def _async_detach_resource_from_connection(self, element_guid: str, connection_guid: str, body: Union[DeleteRelationshipRequestBody, dict] = None): + """Detach an element from the connection that accesses its digital resource. Async version.""" + url = f"{self.base_url}/elements/{element_guid}/connections/{connection_guid}/detach" + await self._async_delete_relationship_request(url, body) + + @dynamic_catch + def detach_resource_from_connection(self, element_guid: str, connection_guid: str, body: Union[DeleteRelationshipRequestBody, dict] = None): + """Detach an element from the connection that accesses its digital resource.""" + loop = asyncio.get_event_loop() + return loop.run_until_complete(self._async_detach_resource_from_connection(element_guid, connection_guid, body)) + async def _async_link_endpoint_to_it_asset(self, asset_guid: str, endpoint_guid: str, body: Union[NewRelationshipRequestBody, dict] = None): url = f"{self.base_url}/assets/{asset_guid}/endpoints/{endpoint_guid}/attach" await self._async_new_relationship_request(url, ["ITAssetEndpoint"], body) diff --git a/tests/micro-tests/test_audit_gap_small_methods.py b/tests/micro-tests/test_audit_gap_small_methods.py new file mode 100644 index 00000000..9e137d0c --- /dev/null +++ b/tests/micro-tests/test_audit_gap_small_methods.py @@ -0,0 +1,136 @@ +""" +Audit-gap methods added from the refreshed Egeria .http collections: + asset-maker: update_data_set_content, detach_data_set_content_relationship + connection-maker: link_resource_to_connection, detach_resource_from_connection +No live server: the HTTP call is captured and the verb, URL and body asserted. +""" +import json + +import pytest + +from pyegeria.omvs.asset_maker import AssetMaker +from pyegeria.omvs.connection_maker import ConnectionMaker + + +class _Response: + def json(self): + return {"class": "VoidResponse", "relatedHTTPCode": 200} + + +def _capture(client, calls: list): + async def fake_make_request(method, url, payload=None, *args, **kwargs): + calls.append({"method": method, "url": url, + "body": json.loads(payload) if isinstance(payload, str) else payload}) + return _Response() + + client._async_make_request = fake_make_request + return client + + +def _asset_maker(calls, monkeypatch): + monkeypatch.setattr(AssetMaker, "check_connection", lambda self: "test") + return _capture(AssetMaker("view-server", "https://localhost:1", "user", "pwd"), calls) + + +def _connection_maker(calls, monkeypatch): + monkeypatch.setattr(ConnectionMaker, "check_connection", lambda self: "test") + return _capture(ConnectionMaker("view-server", "https://localhost:1", "user", "pwd"), calls) + + +ASSET_ROOT = "/servers/view-server/api/open-metadata/asset-maker" +CONN_ROOT = "/servers/view-server/api/open-metadata/connection-maker" + + +@pytest.mark.asyncio +async def test_update_data_set_content_addresses_the_relationship_by_its_own_guid(monkeypatch): + calls: list = [] + client = _asset_maker(calls, monkeypatch) + props = {"class": "DataSetContentProperties", "queryId": "q1", "query": "select 1", + "queryType": "SQL", "iscQualifiedName": "isc::one"} + + await client._async_update_data_set_content( + "rel-guid", {"class": "UpdateRelationshipRequestBody", "mergeUpdate": True, "properties": props}) + + assert calls[0]["method"] == "POST" + assert calls[0]["url"].endswith(f"{ASSET_ROOT}/data-set-content/rel-guid/update") + assert calls[0]["body"]["class"] == "UpdateRelationshipRequestBody" + assert calls[0]["body"]["mergeUpdate"] is True + # none of the DataSetContent properties is dropped on the way out + assert calls[0]["body"]["properties"] == props + + +@pytest.mark.asyncio +async def test_detach_data_set_content_relationship_with_body(monkeypatch): + calls: list = [] + client = _asset_maker(calls, monkeypatch) + + await client._async_detach_data_set_content_relationship( + "rel-guid", {"class": "DeleteRelationshipRequestBody", "externalSourceGUID": "src"}) + + assert calls[0]["method"] == "POST" + assert calls[0]["url"].endswith(f"{ASSET_ROOT}/data-set-content/rel-guid/detach") + assert calls[0]["body"]["class"] == "DeleteRelationshipRequestBody" + assert calls[0]["body"]["externalSourceGUID"] == "src" + + +@pytest.mark.asyncio +async def test_detach_data_set_content_relationship_without_body(monkeypatch): + calls: list = [] + client = _asset_maker(calls, monkeypatch) + + await client._async_detach_data_set_content_relationship("rel-guid") + + assert calls[0]["url"].endswith(f"{ASSET_ROOT}/data-set-content/rel-guid/detach") + assert calls[0]["body"] is None + + +@pytest.mark.asyncio +async def test_new_methods_do_not_reuse_the_two_element_paths(monkeypatch): + # the older attach/detach address the two linked elements; the new ones must not + calls: list = [] + client = _asset_maker(calls, monkeypatch) + + await client._async_detach_data_set_content_relationship("rel-guid") + + assert "/data-sets/" not in calls[0]["url"] + + +@pytest.mark.asyncio +async def test_link_resource_to_connection(monkeypatch): + calls: list = [] + client = _connection_maker(calls, monkeypatch) + props = {"class": "ResourceConnectionProperties", "label": "primary", "description": "d"} + + await client._async_link_resource_to_connection( + "element-guid", "connection-guid", {"class": "NewRelationshipRequestBody", "properties": props}) + + assert calls[0]["method"] == "POST" + assert calls[0]["url"].endswith(f"{CONN_ROOT}/elements/element-guid/connections/connection-guid/attach") + assert calls[0]["body"]["class"] == "NewRelationshipRequestBody" + assert calls[0]["body"]["properties"] == props + + +@pytest.mark.asyncio +async def test_detach_resource_from_connection_sends_delete_options(monkeypatch): + calls: list = [] + client = _connection_maker(calls, monkeypatch) + + await client._async_detach_resource_from_connection( + "element-guid", "connection-guid", + {"class": "DeleteRelationshipRequestBody", "deleteMethod": "SOFT_DELETE"}) + + assert calls[0]["method"] == "POST" + assert calls[0]["url"].endswith(f"{CONN_ROOT}/elements/element-guid/connections/connection-guid/detach") + assert calls[0]["body"]["deleteMethod"] == "SOFT_DELETE" + + +@pytest.mark.asyncio +async def test_link_resource_is_distinct_from_the_asset_only_path(monkeypatch): + calls: list = [] + client = _connection_maker(calls, monkeypatch) + + await client._async_link_resource_to_connection("e", "c", {"class": "NewRelationshipRequestBody"}) + await client._async_link_asset_to_connection("a", "c", {"class": "NewRelationshipRequestBody"}) + + assert "/elements/e/connections/c/attach" in calls[0]["url"] + assert "/assets/a/connections/c/attach" in calls[1]["url"]