diff --git a/libs/labelbox/src/labelbox/data/annotation_types/relationship.py b/libs/labelbox/src/labelbox/data/annotation_types/relationship.py index 0e9c4e934..760c6a529 100644 --- a/libs/labelbox/src/labelbox/data/annotation_types/relationship.py +++ b/libs/labelbox/src/labelbox/data/annotation_types/relationship.py @@ -1,6 +1,7 @@ -from typing import Union -from pydantic import BaseModel +from typing import Union, Optional +from pydantic import BaseModel, model_validator from enum import Enum +import warnings from labelbox.data.annotation_types.annotation import ( BaseAnnotation, ObjectAnnotation, @@ -16,6 +17,15 @@ class Type(Enum): source: Union[ObjectAnnotation, ClassificationAnnotation] target: ObjectAnnotation type: Type = Type.UNIDIRECTIONAL + readonly: Optional[bool] = None + + @model_validator(mode='after') + def check_readonly(self): + if self.readonly is True: + warnings.warn( + "Creating a relationship with readonly=True is in beta and its behavior may change in future releases.", + ) + return self class RelationshipAnnotation(BaseAnnotation): diff --git a/libs/labelbox/src/labelbox/data/serialization/ndjson/relationship.py b/libs/labelbox/src/labelbox/data/serialization/ndjson/relationship.py index f692bae41..6ed4d4ac6 100644 --- a/libs/labelbox/src/labelbox/data/serialization/ndjson/relationship.py +++ b/libs/labelbox/src/labelbox/data/serialization/ndjson/relationship.py @@ -1,3 +1,4 @@ +from typing import Optional from pydantic import BaseModel from .base import NDAnnotation, DataRow from ...annotation_types.data import GenericDataRowData @@ -13,7 +14,7 @@ class _Relationship(BaseModel): source: str target: str type: str - + readonly: Optional[bool] = None class NDRelationship(NDAnnotation): relationship: _Relationship @@ -30,6 +31,7 @@ def to_common( source=source, target=target, type=Relationship.Type(annotation.relationship.type), + readonly=annotation.relationship.readonly, ), extra={"uuid": annotation.uuid}, feature_schema_id=annotation.schema_id, @@ -50,5 +52,6 @@ def from_common( source=str(relationship.source._uuid), target=str(relationship.target._uuid), type=relationship.type.value, + readonly=relationship.readonly, ), ) diff --git a/libs/labelbox/tests/data/annotation_import/test_relationships.py b/libs/labelbox/tests/data/annotation_import/test_relationships.py index f4a80dab9..36d59db72 100644 --- a/libs/labelbox/tests/data/annotation_import/test_relationships.py +++ b/libs/labelbox/tests/data/annotation_import/test_relationships.py @@ -337,3 +337,72 @@ def test_classification_relationship_restrictions(): data={"global_key": "test_key"}, annotations=[relationship] ) list(NDJsonConverter.serialize([label])) + + +def test_relationship_readonly_default_none(): + """Test that relationship readonly field defaults to None when not specified.""" + source = ObjectAnnotation( + name="e1", + value=TextEntity(start=10, end=12), + ) + target = ObjectAnnotation( + name="e2", + value=TextEntity(start=30, end=35), + ) + + relationship = RelationshipAnnotation( + name="rel", + value=Relationship( + source=source, + target=target, + type=Relationship.Type.UNIDIRECTIONAL, + ), + ) + assert relationship.value.readonly is None + + +def test_relationship_readonly_explicit_false(): + """Test that relationship readonly field can be explicitly set to False.""" + source = ObjectAnnotation( + name="e1", + value=TextEntity(start=10, end=12), + ) + target = ObjectAnnotation( + name="e2", + value=TextEntity(start=30, end=35), + ) + + relationship = RelationshipAnnotation( + name="rel", + value=Relationship( + source=source, + target=target, + type=Relationship.Type.UNIDIRECTIONAL, + readonly=False, + ), + ) + assert relationship.value.readonly is False + + +def test_relationship_readonly_explicit_true(): + """Test that setting relationship readonly=True triggers a warning.""" + source = ObjectAnnotation( + name="e1", + value=TextEntity(start=10, end=12), + ) + target = ObjectAnnotation( + name="e2", + value=TextEntity(start=30, end=35), + ) + + with pytest.warns(UserWarning, match="Creating a relationship with readonly=True is in beta.*"): + relationship = RelationshipAnnotation( + name="rel", + value=Relationship( + source=source, + target=target, + type=Relationship.Type.UNIDIRECTIONAL, + readonly=True, + ), + ) + assert relationship.value.readonly is True diff --git a/libs/labelbox/tests/data/serialization/ndjson/test_relationship.py b/libs/labelbox/tests/data/serialization/ndjson/test_relationship.py index 13ff088aa..bdd3816e7 100644 --- a/libs/labelbox/tests/data/serialization/ndjson/test_relationship.py +++ b/libs/labelbox/tests/data/serialization/ndjson/test_relationship.py @@ -192,3 +192,82 @@ def test_bidirectional_relationship(): ) assert rel_serialized["relationship"]["type"] == "bidirectional" assert rel_2_serialized["relationship"]["type"] == "bidirectional" + + +def test_readonly_relationships(): + ner_source = ObjectAnnotation( + name="e1", + value=TextEntity(start=10, end=12), + ) + + ner_target = ObjectAnnotation( + name="e2", + value=TextEntity(start=30, end=35), + ) + + # Test unidirectional relationship with readonly=True + readonly_relationship = RelationshipAnnotation( + name="readonly_rel", + value=Relationship( + source=ner_source, + target=ner_target, + type=Relationship.Type.UNIDIRECTIONAL, + readonly=True, + ), + ) + + # Test bidirectional relationship with readonly=False + non_readonly_relationship = RelationshipAnnotation( + name="non_readonly_rel", + value=Relationship( + source=ner_source, + target=ner_target, + type=Relationship.Type.BIDIRECTIONAL, + readonly=False, + ), + ) + + label = Label( + data={"uid": "clqbkpy236syk07978v3pscw1"}, + annotations=[ + ner_source, + ner_target, + readonly_relationship, + non_readonly_relationship, + ], + ) + + serialized_label = list(NDJsonConverter.serialize([label])) + + ner_source_serialized = next( + annotation + for annotation in serialized_label + if annotation["name"] == ner_source.name + ) + ner_target_serialized = next( + annotation + for annotation in serialized_label + if annotation["name"] == ner_target.name + ) + readonly_rel_serialized = next( + annotation + for annotation in serialized_label + if annotation["name"] == readonly_relationship.name + ) + non_readonly_rel_serialized = next( + annotation + for annotation in serialized_label + if annotation["name"] == non_readonly_relationship.name + ) + + # Verify readonly relationship + assert readonly_rel_serialized["relationship"]["source"] == ner_source_serialized["uuid"] + assert readonly_rel_serialized["relationship"]["target"] == ner_target_serialized["uuid"] + assert readonly_rel_serialized["relationship"]["type"] == "unidirectional" + assert readonly_rel_serialized["relationship"]["readonly"] is True + + # Verify non-readonly relationship + assert non_readonly_rel_serialized["relationship"]["source"] == ner_source_serialized["uuid"] + assert non_readonly_rel_serialized["relationship"]["target"] == ner_target_serialized["uuid"] + assert non_readonly_rel_serialized["relationship"]["type"] == "bidirectional" + assert non_readonly_rel_serialized["relationship"]["readonly"] is False