Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion fairgraph/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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/<uuid>"), 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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}")

Expand Down
147 changes: 145 additions & 2 deletions test/test_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import os
import pytest

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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},
}