diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d68d29..4459b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to this project will be documented in this file - replace naming of classes and methods using 'Workbench' to 'Bench' - replace create_sample, create_container long argument lists with new XXXPost objects +## Unreleased + +- Added support for the Inventory "Link" extra-field type (server PR #803 / + RSDEV-1131): a new `ExtraFieldType.LINK`, a `RelationType` enum of the 40 + DataCite/PIDINST relationship values, and an `InventoryLink` value object. + `ExtraField` now accepts Link fields, with an `ExtraField.link(...)` + convenience constructor. `TemplateBuilder`/`InstrumentTemplateBuilder` gain a + `link()` field with an optional relationship-type whitelist, and the client + gains `get_link_target_summary()` and `get_referencing_items()`. See + `examples/inventory_link_field.py`. Requires an RSpace server that supports + Link fields; integration tests are opt-in via `RSPACE_SUPPORTS_LINK_FIELDS`. + ## 2.7.0 2026-07-03 - Added support for Inventory Instruments and Instrument Templates (beta RSpace 2.24 diff --git a/examples/inventory_link_field.py b/examples/inventory_link_field.py new file mode 100644 index 0000000..c196c02 --- /dev/null +++ b/examples/inventory_link_field.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Demonstrates Inventory "Link" extra-fields: typed, versioned links between +inventory items (and to ELN records). + +Requires an RSpace server that supports Inventory Link fields +(server PR #803 / RSDEV-1131). +""" +import rspace_client +from rspace_client.inv.inv import ExtraField, RelationType +from rspace_client.inv.template_builder import TemplateBuilder + +inv_api = rspace_client.utils.createInventoryClient() + +# Two samples: one will link to the other. +target = inv_api.create_sample("Reference material") +source = inv_api.create_sample("Derived sample") + +print(f"Created target {target['globalId']} and source {source['globalId']}") + +# Add a Link extra-field to the source, pointing at the target with a +# relationship type from the DataCite/PIDINST vocabulary. +link_field = ExtraField.link( + "Derived from", + RelationType.IS_DERIVED_FROM, + target["globalId"], +) +updated = inv_api.add_extra_fields(source["globalId"], link_field) +print(f"Source now has {len(updated['extraFields'])} extra field(s)") + +# A raw string relationship type is also accepted, and a version pin can pin +# the link to a specific version of the target. +pinned = ExtraField.link("Cites", "Cites", target["globalId"], version_pin=1) + +# Back-references: which items link to the target? +referencing = inv_api.get_referencing_items(target["globalId"]) +print(f"Items referencing the target: {referencing}") + +# A read-time summary of a link target (name, type, deleted/readable state). +summary = inv_api.get_link_target_summary(target["globalId"]) +print(f"Target summary: {summary}") + +# Sample templates can define Link fields, optionally whitelisting which +# relationship types are permitted for samples created from the template. +template_post = ( + TemplateBuilder("Linked-sample template", "ml") + .link( + "Related items", + allowed_relation_types=[RelationType.IS_DERIVED_FROM, RelationType.CITES], + mandatory=False, + ) + .build() +) +template = inv_api.create_sample_template(template_post) +print(f"Created template {template['globalId']} with a Link field") diff --git a/rspace_client/inv/inv.py b/rspace_client/inv/inv.py index 6267ec9..fbf9479 100644 --- a/rspace_client/inv/inv.py +++ b/rspace_client/inv/inv.py @@ -690,6 +690,119 @@ def _check(self, prefix, maybe: bool): class ExtraFieldType(Enum): TEXT = "text" NUMBER = "number" + LINK = "link" + + +class RelationType(Enum): + """ + The vocabulary of relationship types permitted on an Inventory Link field. + + These are the DataCite 4.7 ``relationType`` values plus the PIDINST + ``IsCalibratedBy``/``Calibrates`` pair. A Link field, or a template Link + field's whitelist, may use any of these values. + """ + + IS_CITED_BY = "IsCitedBy" + CITES = "Cites" + IS_SUPPLEMENT_TO = "IsSupplementTo" + IS_SUPPLEMENTED_BY = "IsSupplementedBy" + IS_CONTINUED_BY = "IsContinuedBy" + CONTINUES = "Continues" + IS_DESCRIBED_BY = "IsDescribedBy" + DESCRIBES = "Describes" + HAS_METADATA = "HasMetadata" + IS_METADATA_FOR = "IsMetadataFor" + HAS_VERSION = "HasVersion" + IS_VERSION_OF = "IsVersionOf" + IS_NEW_VERSION_OF = "IsNewVersionOf" + IS_PREVIOUS_VERSION_OF = "IsPreviousVersionOf" + IS_PART_OF = "IsPartOf" + HAS_PART = "HasPart" + IS_PUBLISHED_IN = "IsPublishedIn" + IS_REFERENCED_BY = "IsReferencedBy" + REFERENCES = "References" + IS_DOCUMENTED_BY = "IsDocumentedBy" + DOCUMENTS = "Documents" + IS_COMPILED_BY = "IsCompiledBy" + COMPILES = "Compiles" + IS_VARIANT_FORM_OF = "IsVariantFormOf" + IS_ORIGINAL_FORM_OF = "IsOriginalFormOf" + IS_IDENTICAL_TO = "IsIdenticalTo" + IS_REVIEWED_BY = "IsReviewedBy" + REVIEWS = "Reviews" + IS_DERIVED_FROM = "IsDerivedFrom" + IS_SOURCE_OF = "IsSourceOf" + IS_REQUIRED_BY = "IsRequiredBy" + REQUIRES = "Requires" + IS_OBSOLETED_BY = "IsObsoletedBy" + OBSOLETES = "Obsoletes" + IS_COLLECTED_BY = "IsCollectedBy" + COLLECTS = "Collects" + IS_TRANSLATION_OF = "IsTranslationOf" + HAS_TRANSLATION = "HasTranslation" + IS_CALIBRATED_BY = "IsCalibratedBy" + CALIBRATES = "Calibrates" + + @classmethod + def is_valid(cls, value: str) -> bool: + """True if ``value`` is one of the known relationType string values.""" + return value in cls._value2member_map_ + + +class InventoryLink: + """ + Value object describing the target of a Link extra-field. + + A link records a ``relation_type`` (how the source relates to the target), + a ``target_global_id`` (the linked record) and an optional ``version_pin`` + that pins the link to a specific version of the target. + + ``relation_type`` accepts either a :class:`RelationType` or a raw string. + Raw strings that are not in the known vocabulary are allowed through with + no client-side error, so callers are not blocked if the server vocabulary + grows ahead of this client; the server remains the source of truth. + """ + + # Global ID prefixes the server accepts as link targets: Inventory items + # (samples, subsamples, containers, instruments, sample templates) and ELN + # records (documents, notebooks, gallery files). + ALLOWED_TARGET_PREFIXES = ("SA", "SS", "IC", "IN", "IT", "SD", "NB", "GL") + + def __init__( + self, + relation_type: Union["RelationType", str], + target_global_id: str, + version_pin: Optional[int] = None, + ): + if isinstance(relation_type, RelationType): + self.relation_type = relation_type.value + else: + self.relation_type = relation_type + if not target_global_id or not str(target_global_id).strip(): + raise ValueError("target_global_id cannot be empty or None") + prefix = re.match(r"^[A-Z]+", str(target_global_id)) + if prefix is None or prefix.group(0) not in self.ALLOWED_TARGET_PREFIXES: + raise ValueError( + f"target_global_id '{target_global_id}' must start with one of " + f"the allowed prefixes {self.ALLOWED_TARGET_PREFIXES}" + ) + self.target_global_id = target_global_id + self.version_pin = version_pin + + def _toDict(self) -> dict: + d = { + "relationType": self.relation_type, + "targetGlobalId": self.target_global_id, + } + if self.version_pin is not None: + d["versionPin"] = self.version_pin + return d + + def __repr__(self): + return ( + f"{self.__class__.__name__} ({self.relation_type!r}, " + f"{self.target_global_id!r}, {self.version_pin!r})" + ) class TemperatureUnit(Enum): @@ -757,7 +870,14 @@ def __eq__(self, o): class ExtraField: """ - The data in the 'content' field must be of the type set in the 'fieldType' field + A custom field that can be added to an Inventory item. + + For ``TEXT`` and ``NUMBER`` fields the value is held in ``content``, which + must match the type set in ``fieldType``. + + For ``LINK`` fields the value is a :class:`InventoryLink` supplied via the + ``link`` argument (or via the :meth:`link` convenience constructor) instead + of ``content``. """ def __init__( @@ -765,10 +885,55 @@ def __init__( name: str, fieldType: ExtraFieldType = ExtraFieldType.TEXT, content: Union[str, int, float] = "", + link: Optional["InventoryLink"] = None, ): - self.data = {"name": name, "type": fieldType.value, "content": content} + if fieldType == ExtraFieldType.LINK: + if link is None: + raise ValueError("A LINK ExtraField requires a 'link' argument") + if content: + raise ValueError("A LINK ExtraField cannot also set 'content'") + self.data = {"name": name, "type": fieldType.value, "link": link._toDict()} + else: + if link is not None: + raise ValueError( + f"'link' is only valid for LINK fields, not {fieldType.value}" + ) + self.data = {"name": name, "type": fieldType.value, "content": content} + + @classmethod + def link( + cls, + name: str, + relation_type: Union["RelationType", str], + target_global_id: str, + version_pin: Optional[int] = None, + ) -> "ExtraField": + """ + Convenience constructor for a Link extra-field. + + Parameters + ---------- + name : str + The field name. + relation_type : RelationType or str + How the source item relates to the target. + target_global_id : str + The Global ID of the linked record (e.g. 'SA123', 'IT42', 'GL9'). + version_pin : int, optional + Pins the link to a specific version of the target. + """ + return cls( + name, + ExtraFieldType.LINK, + link=InventoryLink(relation_type, target_global_id, version_pin), + ) def __repr__(self): + if self.data["type"] == ExtraFieldType.LINK.value: + return ( + f"{self.__class__.__name__} ({self.data['name']!r}, " + f"{self.data['type']!r}, {self.data['link']!r})" + ) return f"{self.__class__.__name__} ({self.data['name']!r}, {self.data['type']!r},\ {self.data['content']!r})" @@ -1541,6 +1706,47 @@ def add_extra_fields(self, item_id: Union[str, dict], *ExtraField) -> dict: params={"extraFields": toPut}, ) + def get_link_target_summary(self, global_id: Union[str, dict]) -> dict: + """ + Returns a read-time summary of an Inventory Link target: its current + state as {globalId, name, type, deleted, readable}, permission-redacted + for the current user (targets the caller cannot read return a + globalId-only summary). + + Parameters + ---------- + global_id : Union[str, dict] + The Global ID (or item dict) of a link target. May be an Inventory + item or an ELN record (document, notebook, gallery file). + + Returns + ------- + dict + The link target summary. + """ + gid = Id(global_id).as_global_id() + return self.retrieve_api_results(f"/linkTargets/{gid}/summary") + + def get_referencing_items(self, global_id: Union[str, dict]) -> dict: + """ + Returns the Inventory items whose Link extra-field points at the + supplied target, i.e. the back-references to ``global_id``. The result + is permission-filtered to sources the current user can read. + + Parameters + ---------- + global_id : Union[str, dict] + The Global ID (or item dict) of the target. May be an Inventory + item or an ELN record (document, notebook, gallery file). + + Returns + ------- + dict + The referencing (linking) items. + """ + gid = Id(global_id).as_global_id() + return self.retrieve_api_results(f"/referencingItems/{gid}") + def get_attachment_by_id(self, attachment_id: Union[str, int]) -> dict: """ Parameters diff --git a/rspace_client/inv/template_builder.py b/rspace_client/inv/template_builder.py index 691fbf5..64f8b5a 100644 --- a/rspace_client/inv/template_builder.py +++ b/rspace_client/inv/template_builder.py @@ -8,6 +8,7 @@ import datetime as dt from rspace_client.inv.quantity_unit import QuantityUnit +from rspace_client.inv.inv import RelationType class FieldDefinitionBuilder: @@ -232,6 +233,51 @@ def uri(self, name: str, uri: str = None): self.fields.append(f) return self + def link( + self, + name: str, + allowed_relation_types: List = None, + mandatory: bool = False, + ): + """ + Defines a Link field: samples created from this template hold typed, + versioned links to other records. + + Parameters + ---------- + name : str + The field name. + allowed_relation_types : List, optional + An optional whitelist of permitted relationship types, each a + :class:`RelationType` or a raw string. Empty or None means all + relationship types are allowed. + mandatory : bool, optional + Whether samples created from this template must set this link. + The default is False. + + Returns + ------- + This object for chaining + + Raises + ------ + ValueError if any supplied relationship type is not a known value. + """ + f = self._set_name(name, "Link") + if allowed_relation_types: + whitelist = [] + for rt in allowed_relation_types: + if isinstance(rt, RelationType): + whitelist.append(rt.value) + elif RelationType.is_valid(rt): + whitelist.append(rt) + else: + raise ValueError(f"{rt!r} is not a known relationship type") + f["allowedRelationTypes"] = whitelist + f["mandatory"] = mandatory + self.fields.append(f) + return self + def field_count(self): return len(self.fields) diff --git a/rspace_client/tests/invapi_test.py b/rspace_client/tests/invapi_test.py index 77392a6..ec66bac 100644 --- a/rspace_client/tests/invapi_test.py +++ b/rspace_client/tests/invapi_test.py @@ -9,6 +9,7 @@ import json import datetime as dt import pprint +import unittest import pytest @@ -1125,3 +1126,170 @@ def test_get_datacite_settings(self): self.assertIsInstance(result, dict) # Should contain datacite section (even if empty/default) self.assertIn("datacite", result) + + def _require_link_field_support(self): + """ + Skips the calling test unless the target server is known to support + Inventory Link fields (RSpace server PR #803 / RSDEV-1131). Released + servers predate the feature, so the tests are opt-in via the + RSPACE_SUPPORTS_LINK_FIELDS environment variable. + """ + if not os.getenv("RSPACE_SUPPORTS_LINK_FIELDS"): + pytest.skip( + "Skipping Link-field test: set RSPACE_SUPPORTS_LINK_FIELDS=1 " + "to run against a server that supports Inventory Link fields" + ) + + def test_add_link_extra_field(self): + self._require_link_field_support() + target = self.invapi.create_sample(base.random_string()) + source = self.invapi.create_sample(base.random_string()) + link_ef = inv.ExtraField.link( + "related", inv.RelationType.IS_DERIVED_FROM, target["globalId"] + ) + updated = self.invapi.add_extra_fields(source["globalId"], link_ef) + link_fields = [ + f for f in updated["extraFields"] if f["type"] == inv.ExtraFieldType.LINK.value + ] + self.assertEqual(1, len(link_fields)) + self.assertEqual( + target["globalId"], link_fields[0]["link"]["targetGlobalId"] + ) + + def test_create_sample_with_link_extra_field(self): + self._require_link_field_support() + target = self.invapi.create_sample(base.random_string()) + link_ef = inv.ExtraField.link( + "cites", inv.RelationType.CITES, target["globalId"] + ) + sample = self.invapi.create_sample( + name=base.random_string(), extra_fields=[link_ef] + ) + link_fields = [ + f for f in sample["extraFields"] if f["type"] == inv.ExtraFieldType.LINK.value + ] + self.assertEqual(1, len(link_fields)) + + def test_get_referencing_items(self): + self._require_link_field_support() + target = self.invapi.create_sample(base.random_string()) + source = self.invapi.create_sample(base.random_string()) + link_ef = inv.ExtraField.link( + "ref", inv.RelationType.REFERENCES, target["globalId"] + ) + self.invapi.add_extra_fields(source["globalId"], link_ef) + referencing = self.invapi.get_referencing_items(target["globalId"]) + self.assertIsInstance(referencing, dict) + + def test_get_link_target_summary(self): + self._require_link_field_support() + target = self.invapi.create_sample(base.random_string()) + summary = self.invapi.get_link_target_summary(target["globalId"]) + self.assertEqual(target["globalId"], summary["globalId"]) + + def test_create_sample_template_with_link_field(self): + self._require_link_field_support() + template_post = ( + template_builder.TemplateBuilder(base.random_string(), "ml") + .link( + "related items", + allowed_relation_types=[inv.RelationType.IS_DERIVED_FROM], + mandatory=False, + ) + .build() + ) + template = self.invapi.create_sample_template(template_post) + link_fields = [ + f for f in template["fields"] if f["type"] == inv.ExtraFieldType.LINK.value + ] + self.assertEqual(1, len(link_fields)) + + +class LinkFieldUnitTest(unittest.TestCase): + """ + Pure unit tests for Inventory Link field value objects and builders that + do not require a live RSpace server. + """ + + def test_inventory_link_to_dict_with_enum(self): + link = inv.InventoryLink( + inv.RelationType.IS_CALIBRATED_BY, "IT42", version_pin=3 + ) + self.assertEqual( + { + "relationType": "IsCalibratedBy", + "targetGlobalId": "IT42", + "versionPin": 3, + }, + link._toDict(), + ) + + def test_inventory_link_accepts_raw_string_relation_type(self): + link = inv.InventoryLink("References", "GL9") + self.assertEqual("References", link._toDict()["relationType"]) + # version pin omitted when not set + self.assertNotIn("versionPin", link._toDict()) + + def test_inventory_link_rejects_disallowed_target_prefix(self): + with self.assertRaises(ValueError): + inv.InventoryLink(inv.RelationType.CITES, "ZZ1") + + def test_inventory_link_rejects_empty_target(self): + with self.assertRaises(ValueError): + inv.InventoryLink(inv.RelationType.CITES, "") + + def test_extra_field_link_serialization(self): + ef = inv.ExtraField.link("f", inv.RelationType.IS_DERIVED_FROM, "SA1") + self.assertEqual("link", ef.data["type"]) + self.assertEqual("SA1", ef.data["link"]["targetGlobalId"]) + self.assertNotIn("content", ef.data) + + def test_link_extra_field_requires_link_argument(self): + with self.assertRaises(ValueError): + inv.ExtraField("f", inv.ExtraFieldType.LINK) + + def test_link_extra_field_rejects_content(self): + with self.assertRaises(ValueError): + inv.ExtraField( + "f", + inv.ExtraFieldType.LINK, + content="x", + link=inv.InventoryLink(inv.RelationType.CITES, "SA1"), + ) + + def test_non_link_field_rejects_link_argument(self): + with self.assertRaises(ValueError): + inv.ExtraField( + "f", + inv.ExtraFieldType.TEXT, + link=inv.InventoryLink(inv.RelationType.CITES, "SA1"), + ) + + def test_relation_type_is_valid(self): + self.assertTrue(inv.RelationType.is_valid("IsCalibratedBy")) + self.assertFalse(inv.RelationType.is_valid("NotARelation")) + + def test_relation_type_vocabulary_size(self): + # 38 DataCite 4.7 values plus the PIDINST IsCalibratedBy/Calibrates pair + self.assertEqual(40, len(list(inv.RelationType))) + + def test_template_builder_link_field(self): + fields = ( + template_builder.TemplateBuilder("t", "ml") + .link( + "links", + allowed_relation_types=[inv.RelationType.CITES, "References"], + mandatory=True, + ) + ._fields() + ) + self.assertEqual(1, len(fields)) + self.assertEqual("Link", fields[0]["type"]) + self.assertEqual(["Cites", "References"], fields[0]["allowedRelationTypes"]) + self.assertTrue(fields[0]["mandatory"]) + + def test_template_builder_link_field_rejects_bad_relation_type(self): + with self.assertRaises(ValueError): + template_builder.TemplateBuilder("t", "ml").link( + "links", allowed_relation_types=["NotARelation"] + )