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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions examples/inventory_link_field.py
Original file line number Diff line number Diff line change
@@ -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")
210 changes: 208 additions & 2 deletions rspace_client/inv/inv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -757,18 +870,70 @@ 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__(
self,
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})"

Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions rspace_client/inv/template_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
Loading
Loading