Skip to content

Commit bd2bb40

Browse files
authored
Merge pull request #144 from apdavison/marmotgraph-v4-link-ids
Expand bare-UUID link ids in KG responses
2 parents b7f116e + 24b27c6 commit bd2bb40

2 files changed

Lines changed: 177 additions & 3 deletions

File tree

‎fairgraph/client.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from __future__ import annotations
2222
import os
2323
import logging
24+
import re
2425
from typing import Any, Dict, Iterable, List, Optional, Union, TYPE_CHECKING
2526
from uuid import uuid4, UUID
2627

@@ -60,6 +61,35 @@
6061
default_response_configuration = ExtendedResponseConfiguration(return_embedded=True)
6162

6263

64+
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}")
65+
66+
67+
def expand_bare_uuids(data: Any, namespace: str) -> Any:
68+
"""
69+
Replace every "@id" whose value is a bare UUID with the full URI of the instance, in place.
70+
71+
Marmotgraph v3 gives links to other KG instances as full URIs
72+
(e.g. "https://kg.ebrains.eu/api/instances/<uuid>"), but Marmotgraph v4 gives them as bare UUIDs,
73+
while still giving the "@id" of the instance itself as a full URI. Expanding the bare UUIDs keeps
74+
ids consistent, so that links can be resolved and compared with the ids of the instances they
75+
point to. Responses containing only full URIs are unchanged.
76+
77+
Returns the (modified) data.
78+
"""
79+
if isinstance(data, dict):
80+
for key, value in data.items():
81+
if key == "@id":
82+
# checking the length first quickly rules out full URIs and other IRIs
83+
if isinstance(value, str) and len(value) == 36 and BARE_UUID.fullmatch(value):
84+
data[key] = f"{namespace}{value}"
85+
else:
86+
expand_bare_uuids(value, namespace)
87+
elif isinstance(data, list):
88+
for item in data:
89+
expand_bare_uuids(item, namespace)
90+
return data
91+
92+
6393
AVAILABLE_PERMISSIONS = [
6494
"CREATE",
6595
"READ",
@@ -217,6 +247,7 @@ def _check_response(
217247
else:
218248
raise Exception(f"Error: {response.error} {error_context}")
219249
else:
250+
expand_bare_uuids(response.data, self._kg_client.instances._kg_config.id_namespace)
220251
return response
221252

222253
def query(
@@ -438,7 +469,7 @@ def _get_instance(release_status):
438469
extended_response_configuration=default_response_configuration,
439470
)
440471
# todo: handle errors
441-
data = response.data[payload[0]].data
472+
data = expand_bare_uuids(response.data[payload[0]].data, kg_namespace)
442473
else:
443474
raise Exception(f"This client cannot retrieve instances from {uri}")
444475

‎test/test_client.py‎

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import os
23
import pytest
34

@@ -321,7 +322,7 @@ def test_create_new_instance(kg_client, mocker):
321322
)
322323
fake_id = "00000000-0000-0000-0000-000000000000"
323324
response = kg_client.create_new_instance({"a": 1, "b": 2}, instance_id=fake_id, space="not-a-real-space")
324-
assert response == {"@id": fake_id, "a": 1, "b": 2}
325+
assert response == {"@id": kg_client.uri_from_uuid(fake_id), "a": 1, "b": 2}
325326

326327

327328
@skip_if_no_connection
@@ -333,7 +334,7 @@ def test_replace_instance(kg_client, mocker):
333334
)
334335
fake_id = "00000000-0000-0000-0000-000000000000"
335336
response = kg_client.replace_instance(fake_id, {"a": 1, "b": 2})
336-
assert response == {"@id": fake_id, "a": 1, "b": 2}
337+
assert response == {"@id": kg_client.uri_from_uuid(fake_id), "a": 1, "b": 2}
337338

338339

339340
@skip_if_no_connection
@@ -553,3 +554,145 @@ def test_clean_space_lists_both_kinds_then_aborts(self, mock_client, mocker, cap
553554
out = capsys.readouterr().out
554555
assert "Person 3" in out
555556
assert f"{self.unknown_type} 7" in out
557+
558+
559+
class TestBareUuidLinks:
560+
"""Marmotgraph v4 gives links to other KG instances as bare UUIDs rather than full URIs.
561+
The client expands them, so that links can be resolved and compared with instance ids."""
562+
563+
namespace = "https://kg.ebrains.eu/api/instances/"
564+
dsv_uuid = "00000000-0000-0000-0000-000000000001"
565+
target_uuid = "00000000-0000-0000-0000-00000000000a"
566+
567+
def dataset_version(self, id_):
568+
return {
569+
"@id": id_,
570+
"@type": ["https://openminds.om-i.org/types/DatasetVersion"],
571+
"http://schema.org/identifier": [self.dsv_uuid, self.namespace + self.dsv_uuid],
572+
"https://core.kg.ebrains.eu/vocab/meta/space": "dataset",
573+
"https://openminds.om-i.org/props/accessibility": {"@id": self.target_uuid},
574+
"https://openminds.om-i.org/props/digitalIdentifier": {"@id": "https://doi.org/10.25493/6640-3XH"},
575+
"https://openminds.om-i.org/props/technique": [
576+
{"@id": "https://openminds.om-i.org/instances/technique/spatialRegistration"},
577+
{"@id": self.target_uuid.upper()},
578+
],
579+
}
580+
581+
def test_expand_bare_uuids(self):
582+
from fairgraph.client import expand_bare_uuids
583+
584+
data = [
585+
{
586+
"@id": self.dsv_uuid,
587+
"http://schema.org/identifier": [self.dsv_uuid],
588+
"https://openminds.om-i.org/props/link": {"@id": self.target_uuid},
589+
"https://openminds.om-i.org/props/links": [
590+
{"@id": self.target_uuid},
591+
{"@id": "https://example.com/x"},
592+
],
593+
"https://openminds.om-i.org/props/embedded": {
594+
"@id": f"{self.dsv_uuid}_emb_1",
595+
"https://openminds.om-i.org/props/nested": {"@id": self.target_uuid},
596+
},
597+
"https://openminds.om-i.org/props/name": self.target_uuid,
598+
}
599+
]
600+
result = expand_bare_uuids(data, self.namespace)
601+
assert result is data
602+
assert data == [
603+
{
604+
"@id": self.namespace + self.dsv_uuid,
605+
"http://schema.org/identifier": [self.dsv_uuid], # not an "@id", so unchanged
606+
"https://openminds.om-i.org/props/link": {"@id": self.namespace + self.target_uuid},
607+
"https://openminds.om-i.org/props/links": [
608+
{"@id": self.namespace + self.target_uuid},
609+
{"@id": "https://example.com/x"},
610+
],
611+
"https://openminds.om-i.org/props/embedded": {
612+
"@id": f"{self.dsv_uuid}_emb_1",
613+
"https://openminds.om-i.org/props/nested": {"@id": self.namespace + self.target_uuid},
614+
},
615+
"https://openminds.om-i.org/props/name": self.target_uuid,
616+
}
617+
]
618+
619+
def test_full_uris_unchanged(self):
620+
from fairgraph.client import expand_bare_uuids
621+
622+
data = self.dataset_version(self.namespace + self.dsv_uuid)
623+
for item in data["https://openminds.om-i.org/props/technique"]:
624+
item["@id"] = "https://openminds.om-i.org/instances/technique/spatialRegistration"
625+
data["https://openminds.om-i.org/props/accessibility"]["@id"] = self.namespace + self.target_uuid
626+
expected = copy.deepcopy(data)
627+
assert expand_bare_uuids(data, self.namespace) == expected
628+
629+
def test_list(self, offline_kg_client, mocker):
630+
mocker.patch.object(
631+
offline_kg_client._kg_client.instances,
632+
"list",
633+
lambda **kw: MockKGResponse([self.dataset_version(self.namespace + self.dsv_uuid)]),
634+
)
635+
data = offline_kg_client.list("https://openminds.om-i.org/types/DatasetVersion").data[0]
636+
assert data["https://openminds.om-i.org/props/accessibility"] == {"@id": self.namespace + self.target_uuid}
637+
assert data["https://openminds.om-i.org/props/technique"] == [
638+
{"@id": "https://openminds.om-i.org/instances/technique/spatialRegistration"},
639+
{"@id": self.namespace + self.target_uuid.upper()},
640+
]
641+
assert data["https://openminds.om-i.org/props/digitalIdentifier"] == {
642+
"@id": "https://doi.org/10.25493/6640-3XH"
643+
}
644+
645+
def test_query(self, offline_kg_client, mocker):
646+
mocker.patch.object(
647+
offline_kg_client._kg_client.queries,
648+
"test_query",
649+
lambda *args, **kw: MockKGResponse([{"@id": self.dsv_uuid, "accessibility": {"@id": self.target_uuid}}]),
650+
)
651+
data = offline_kg_client.query({"@context": {}, "structure": []}).data
652+
assert data == [
653+
{"@id": self.namespace + self.dsv_uuid, "accessibility": {"@id": self.namespace + self.target_uuid}}
654+
]
655+
656+
def test_resolve_link(self, offline_kg_client, mocker):
657+
from fairgraph.openminds.core import DatasetVersion
658+
from fairgraph.openminds.controlled_terms import ProductAccessibility
659+
660+
server = {
661+
self.dsv_uuid: self.dataset_version(self.namespace + self.dsv_uuid),
662+
self.target_uuid: {
663+
"@id": self.namespace + self.target_uuid,
664+
"@type": ["https://openminds.om-i.org/types/ProductAccessibility"],
665+
"http://schema.org/identifier": [self.namespace + self.target_uuid],
666+
"https://core.kg.ebrains.eu/vocab/meta/space": "controlled",
667+
"https://openminds.om-i.org/props/name": "controlled access",
668+
},
669+
}
670+
mocker.patch.object(
671+
offline_kg_client._kg_client.instances,
672+
"get_by_id",
673+
lambda stage, instance_id, extended_response_configuration: MockKGResponse(
674+
copy.deepcopy(server[str(instance_id)])
675+
),
676+
)
677+
dsv = DatasetVersion.from_id(self.dsv_uuid, offline_kg_client)
678+
assert dsv.accessibility.id == self.namespace + self.target_uuid
679+
accessibility = dsv.accessibility.resolve(offline_kg_client)
680+
assert isinstance(accessibility, ProductAccessibility)
681+
assert accessibility.name == "controlled access"
682+
assert accessibility.id == dsv.accessibility.id
683+
684+
def test_openminds_instance(self, offline_kg_client, mocker):
685+
uri = "https://openminds.om-i.org/instances/technique/spatialRegistration"
686+
result = mocker.Mock(
687+
data={"@id": self.target_uuid, "https://openminds.om-i.org/props/link": {"@id": self.dsv_uuid}}
688+
)
689+
mocker.patch.object(
690+
offline_kg_client._kg_client.instances,
691+
"get_by_identifiers",
692+
lambda **kw: mocker.Mock(data={uri: result}),
693+
)
694+
data = offline_kg_client.instance_from_full_uri(uri, use_cache=False, require_full_data=False)
695+
assert data == {
696+
"@id": self.namespace + self.target_uuid,
697+
"https://openminds.om-i.org/props/link": {"@id": self.namespace + self.dsv_uuid},
698+
}

0 commit comments

Comments
 (0)