From 24b27c64bff44ab43de24076f25871572071175a Mon Sep 17 00:00:00 2001 From: Andrew Davison Date: Tue, 15 Sep 2026 15:31:23 +0200 Subject: [PATCH] Expand bare-UUID link ids in KG responses Marmotgraph v4 gives links to other KG instances as bare UUIDs rather than full URIs, while still giving the id of the instance itself as a full URI. KGClient now expands bare UUIDs in "@id" values to full URIs in all responses, so that links can be resolved and compared with the ids of the instances they point to. Responses from Marmotgraph v3, which contain only full URIs, are unchanged. --- fairgraph/client.py | 33 +++++++++- test/test_client.py | 147 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/fairgraph/client.py b/fairgraph/client.py index 5aa67029..3a21a3b8 100644 --- a/fairgraph/client.py +++ b/fairgraph/client.py @@ -21,6 +21,7 @@ from __future__ import annotations import os import logging +import re from typing import Any, Dict, Iterable, List, Optional, Union, TYPE_CHECKING from uuid import uuid4, UUID @@ -60,6 +61,35 @@ default_response_configuration = ExtendedResponseConfiguration(return_embedded=True) +BARE_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + +def expand_bare_uuids(data: Any, namespace: str) -> Any: + """ + Replace every "@id" whose value is a bare UUID with the full URI of the instance, in place. + + Marmotgraph v3 gives links to other KG instances as full URIs + (e.g. "https://kg.ebrains.eu/api/instances/"), but Marmotgraph v4 gives them as bare UUIDs, + while still giving the "@id" of the instance itself as a full URI. Expanding the bare UUIDs keeps + ids consistent, so that links can be resolved and compared with the ids of the instances they + point to. Responses containing only full URIs are unchanged. + + Returns the (modified) data. + """ + if isinstance(data, dict): + for key, value in data.items(): + if key == "@id": + # checking the length first quickly rules out full URIs and other IRIs + if isinstance(value, str) and len(value) == 36 and BARE_UUID.fullmatch(value): + data[key] = f"{namespace}{value}" + else: + expand_bare_uuids(value, namespace) + elif isinstance(data, list): + for item in data: + expand_bare_uuids(item, namespace) + return data + + AVAILABLE_PERMISSIONS = [ "CREATE", "READ", @@ -217,6 +247,7 @@ def _check_response( else: raise Exception(f"Error: {response.error} {error_context}") else: + expand_bare_uuids(response.data, self._kg_client.instances._kg_config.id_namespace) return response def query( @@ -438,7 +469,7 @@ def _get_instance(release_status): extended_response_configuration=default_response_configuration, ) # todo: handle errors - data = response.data[payload[0]].data + data = expand_bare_uuids(response.data[payload[0]].data, kg_namespace) else: raise Exception(f"This client cannot retrieve instances from {uri}") diff --git a/test/test_client.py b/test/test_client.py index 794eb8dd..c5e621b0 100644 --- a/test/test_client.py +++ b/test/test_client.py @@ -1,3 +1,4 @@ +import copy import os import pytest @@ -321,7 +322,7 @@ def test_create_new_instance(kg_client, mocker): ) fake_id = "00000000-0000-0000-0000-000000000000" response = kg_client.create_new_instance({"a": 1, "b": 2}, instance_id=fake_id, space="not-a-real-space") - assert response == {"@id": fake_id, "a": 1, "b": 2} + assert response == {"@id": kg_client.uri_from_uuid(fake_id), "a": 1, "b": 2} @skip_if_no_connection @@ -333,7 +334,7 @@ def test_replace_instance(kg_client, mocker): ) fake_id = "00000000-0000-0000-0000-000000000000" response = kg_client.replace_instance(fake_id, {"a": 1, "b": 2}) - assert response == {"@id": fake_id, "a": 1, "b": 2} + assert response == {"@id": kg_client.uri_from_uuid(fake_id), "a": 1, "b": 2} @skip_if_no_connection @@ -553,3 +554,145 @@ def test_clean_space_lists_both_kinds_then_aborts(self, mock_client, mocker, cap out = capsys.readouterr().out assert "Person 3" in out assert f"{self.unknown_type} 7" in out + + +class TestBareUuidLinks: + """Marmotgraph v4 gives links to other KG instances as bare UUIDs rather than full URIs. + The client expands them, so that links can be resolved and compared with instance ids.""" + + namespace = "https://kg.ebrains.eu/api/instances/" + dsv_uuid = "00000000-0000-0000-0000-000000000001" + target_uuid = "00000000-0000-0000-0000-00000000000a" + + def dataset_version(self, id_): + return { + "@id": id_, + "@type": ["https://openminds.om-i.org/types/DatasetVersion"], + "http://schema.org/identifier": [self.dsv_uuid, self.namespace + self.dsv_uuid], + "https://core.kg.ebrains.eu/vocab/meta/space": "dataset", + "https://openminds.om-i.org/props/accessibility": {"@id": self.target_uuid}, + "https://openminds.om-i.org/props/digitalIdentifier": {"@id": "https://doi.org/10.25493/6640-3XH"}, + "https://openminds.om-i.org/props/technique": [ + {"@id": "https://openminds.om-i.org/instances/technique/spatialRegistration"}, + {"@id": self.target_uuid.upper()}, + ], + } + + def test_expand_bare_uuids(self): + from fairgraph.client import expand_bare_uuids + + data = [ + { + "@id": self.dsv_uuid, + "http://schema.org/identifier": [self.dsv_uuid], + "https://openminds.om-i.org/props/link": {"@id": self.target_uuid}, + "https://openminds.om-i.org/props/links": [ + {"@id": self.target_uuid}, + {"@id": "https://example.com/x"}, + ], + "https://openminds.om-i.org/props/embedded": { + "@id": f"{self.dsv_uuid}_emb_1", + "https://openminds.om-i.org/props/nested": {"@id": self.target_uuid}, + }, + "https://openminds.om-i.org/props/name": self.target_uuid, + } + ] + result = expand_bare_uuids(data, self.namespace) + assert result is data + assert data == [ + { + "@id": self.namespace + self.dsv_uuid, + "http://schema.org/identifier": [self.dsv_uuid], # not an "@id", so unchanged + "https://openminds.om-i.org/props/link": {"@id": self.namespace + self.target_uuid}, + "https://openminds.om-i.org/props/links": [ + {"@id": self.namespace + self.target_uuid}, + {"@id": "https://example.com/x"}, + ], + "https://openminds.om-i.org/props/embedded": { + "@id": f"{self.dsv_uuid}_emb_1", + "https://openminds.om-i.org/props/nested": {"@id": self.namespace + self.target_uuid}, + }, + "https://openminds.om-i.org/props/name": self.target_uuid, + } + ] + + def test_full_uris_unchanged(self): + from fairgraph.client import expand_bare_uuids + + data = self.dataset_version(self.namespace + self.dsv_uuid) + for item in data["https://openminds.om-i.org/props/technique"]: + item["@id"] = "https://openminds.om-i.org/instances/technique/spatialRegistration" + data["https://openminds.om-i.org/props/accessibility"]["@id"] = self.namespace + self.target_uuid + expected = copy.deepcopy(data) + assert expand_bare_uuids(data, self.namespace) == expected + + def test_list(self, offline_kg_client, mocker): + mocker.patch.object( + offline_kg_client._kg_client.instances, + "list", + lambda **kw: MockKGResponse([self.dataset_version(self.namespace + self.dsv_uuid)]), + ) + data = offline_kg_client.list("https://openminds.om-i.org/types/DatasetVersion").data[0] + assert data["https://openminds.om-i.org/props/accessibility"] == {"@id": self.namespace + self.target_uuid} + assert data["https://openminds.om-i.org/props/technique"] == [ + {"@id": "https://openminds.om-i.org/instances/technique/spatialRegistration"}, + {"@id": self.namespace + self.target_uuid.upper()}, + ] + assert data["https://openminds.om-i.org/props/digitalIdentifier"] == { + "@id": "https://doi.org/10.25493/6640-3XH" + } + + def test_query(self, offline_kg_client, mocker): + mocker.patch.object( + offline_kg_client._kg_client.queries, + "test_query", + lambda *args, **kw: MockKGResponse([{"@id": self.dsv_uuid, "accessibility": {"@id": self.target_uuid}}]), + ) + data = offline_kg_client.query({"@context": {}, "structure": []}).data + assert data == [ + {"@id": self.namespace + self.dsv_uuid, "accessibility": {"@id": self.namespace + self.target_uuid}} + ] + + def test_resolve_link(self, offline_kg_client, mocker): + from fairgraph.openminds.core import DatasetVersion + from fairgraph.openminds.controlled_terms import ProductAccessibility + + server = { + self.dsv_uuid: self.dataset_version(self.namespace + self.dsv_uuid), + self.target_uuid: { + "@id": self.namespace + self.target_uuid, + "@type": ["https://openminds.om-i.org/types/ProductAccessibility"], + "http://schema.org/identifier": [self.namespace + self.target_uuid], + "https://core.kg.ebrains.eu/vocab/meta/space": "controlled", + "https://openminds.om-i.org/props/name": "controlled access", + }, + } + mocker.patch.object( + offline_kg_client._kg_client.instances, + "get_by_id", + lambda stage, instance_id, extended_response_configuration: MockKGResponse( + copy.deepcopy(server[str(instance_id)]) + ), + ) + dsv = DatasetVersion.from_id(self.dsv_uuid, offline_kg_client) + assert dsv.accessibility.id == self.namespace + self.target_uuid + accessibility = dsv.accessibility.resolve(offline_kg_client) + assert isinstance(accessibility, ProductAccessibility) + assert accessibility.name == "controlled access" + assert accessibility.id == dsv.accessibility.id + + def test_openminds_instance(self, offline_kg_client, mocker): + uri = "https://openminds.om-i.org/instances/technique/spatialRegistration" + result = mocker.Mock( + data={"@id": self.target_uuid, "https://openminds.om-i.org/props/link": {"@id": self.dsv_uuid}} + ) + mocker.patch.object( + offline_kg_client._kg_client.instances, + "get_by_identifiers", + lambda **kw: mocker.Mock(data={uri: result}), + ) + data = offline_kg_client.instance_from_full_uri(uri, use_cache=False, require_full_data=False) + assert data == { + "@id": self.namespace + self.target_uuid, + "https://openminds.om-i.org/props/link": {"@id": self.namespace + self.dsv_uuid}, + }