From cbf50aa64e492d4e3ba627bc6e02f635f8501292 Mon Sep 17 00:00:00 2001 From: Karol Jamrozy Date: Tue, 15 Apr 2025 23:04:47 +0200 Subject: [PATCH 1/4] [PTDT-4605] Add ability to specify relationship constraints --- libs/labelbox/src/labelbox/schema/ontology.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/ontology.py b/libs/labelbox/src/labelbox/schema/ontology.py index 1cc827c5f..252c88c0f 100644 --- a/libs/labelbox/src/labelbox/schema/ontology.py +++ b/libs/labelbox/src/labelbox/schema/ontology.py @@ -4,7 +4,7 @@ import json from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union, Tuple from lbox.exceptions import InconsistentOntologyException @@ -71,6 +71,15 @@ class Tool: instructions = "Classification Example") tool.add_classification(classification) + relationship_tool = Tool( + tool = Tool.Type.RELATIONSHIP, + name = "Relationship Tool Example", + constraints = [ + ("source_tool_feature_schema_id_1", "target_tool_feature_schema_id_1"), + ("source_tool_feature_schema_id_2", "target_tool_feature_schema_id_2") + ] + ) + Attributes: tool: (Tool.Type) name: (str) @@ -80,6 +89,7 @@ class Tool: schema_id: (str) feature_schema_id: (str) attributes: (list) + constraints: (list of [str, str]) (only available for RELATIONSHIP tool type) """ class Type(Enum): @@ -103,8 +113,14 @@ class Type(Enum): schema_id: Optional[str] = None feature_schema_id: Optional[str] = None attributes: Optional[FeatureSchemaAttributes] = None + constraints: Optional[Tuple[str, str]] = None def __post_init__(self): + if self.constraints is not None and self.tool != Tool.Type.RELATIONSHIP: + warnings.warn( + "The constraints attribute is only available for Relationship tool. The provided constraints will be ignored." + ) + self.constraints = None if self.attributes is not None: warnings.warn( "The attributes for Tools are in beta. The attribute name and signature may change in the future." @@ -112,12 +128,13 @@ def __post_init__(self): @classmethod def from_dict(cls, dictionary: Dict[str, Any]) -> Dict[str, Any]: + tool = Tool.Type(dictionary["tool"]) return cls( name=dictionary["name"], schema_id=dictionary.get("schemaNodeId", None), feature_schema_id=dictionary.get("featureSchemaId", None), required=dictionary.get("required", False), - tool=Tool.Type(dictionary["tool"]), + tool=tool, classifications=[ Classification.from_dict(c) for c in dictionary["classifications"] @@ -129,6 +146,9 @@ def from_dict(cls, dictionary: Dict[str, Any]) -> Dict[str, Any]: ] if dictionary.get("attributes") else None, + constraints=dictionary.get("constraints", None) + if tool == Tool.Type.RELATIONSHIP + else None, ) def asdict(self) -> Dict[str, Any]: @@ -145,6 +165,9 @@ def asdict(self) -> Dict[str, Any]: "attributes": [a.asdict() for a in self.attributes] if self.attributes is not None else None, + "constraints": self.constraints + if self.constraints is not None + else None, } def add_classification(self, classification: Classification) -> None: From 7add87db404db8bb0122b9e395e86cd2f9322470 Mon Sep 17 00:00:00 2001 From: Karol Jamrozy Date: Tue, 27 May 2025 18:47:53 +0200 Subject: [PATCH 2/4] [PTDT-4605] Add ability to specify relationship constraints --- libs/labelbox/src/labelbox/schema/ontology.py | 77 +++++++++++++------ 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/ontology.py b/libs/labelbox/src/labelbox/schema/ontology.py index 252c88c0f..d5cceef8e 100644 --- a/libs/labelbox/src/labelbox/schema/ontology.py +++ b/libs/labelbox/src/labelbox/schema/ontology.py @@ -71,15 +71,6 @@ class Tool: instructions = "Classification Example") tool.add_classification(classification) - relationship_tool = Tool( - tool = Tool.Type.RELATIONSHIP, - name = "Relationship Tool Example", - constraints = [ - ("source_tool_feature_schema_id_1", "target_tool_feature_schema_id_1"), - ("source_tool_feature_schema_id_2", "target_tool_feature_schema_id_2") - ] - ) - Attributes: tool: (Tool.Type) name: (str) @@ -89,7 +80,6 @@ class Tool: schema_id: (str) feature_schema_id: (str) attributes: (list) - constraints: (list of [str, str]) (only available for RELATIONSHIP tool type) """ class Type(Enum): @@ -113,14 +103,8 @@ class Type(Enum): schema_id: Optional[str] = None feature_schema_id: Optional[str] = None attributes: Optional[FeatureSchemaAttributes] = None - constraints: Optional[Tuple[str, str]] = None def __post_init__(self): - if self.constraints is not None and self.tool != Tool.Type.RELATIONSHIP: - warnings.warn( - "The constraints attribute is only available for Relationship tool. The provided constraints will be ignored." - ) - self.constraints = None if self.attributes is not None: warnings.warn( "The attributes for Tools are in beta. The attribute name and signature may change in the future." @@ -128,13 +112,12 @@ def __post_init__(self): @classmethod def from_dict(cls, dictionary: Dict[str, Any]) -> Dict[str, Any]: - tool = Tool.Type(dictionary["tool"]) return cls( name=dictionary["name"], schema_id=dictionary.get("schemaNodeId", None), feature_schema_id=dictionary.get("featureSchemaId", None), required=dictionary.get("required", False), - tool=tool, + tool=Tool.Type(dictionary["tool"]), classifications=[ Classification.from_dict(c) for c in dictionary["classifications"] @@ -146,9 +129,6 @@ def from_dict(cls, dictionary: Dict[str, Any]) -> Dict[str, Any]: ] if dictionary.get("attributes") else None, - constraints=dictionary.get("constraints", None) - if tool == Tool.Type.RELATIONSHIP - else None, ) def asdict(self) -> Dict[str, Any]: @@ -165,9 +145,6 @@ def asdict(self) -> Dict[str, Any]: "attributes": [a.asdict() for a in self.attributes] if self.attributes is not None else None, - "constraints": self.constraints - if self.constraints is not None - else None, } def add_classification(self, classification: Classification) -> None: @@ -178,6 +155,56 @@ def add_classification(self, classification: Classification) -> None: ) self.classifications.append(classification) +@dataclass +class RelationshipTool(Tool): + """ + A relationship tool to be added to a Project's ontology. + + To instantiate, the "tool" and "name" parameters must + be passed in. + + The "classifications" parameter holds a list of Classification objects. + This can be used to add nested classifications to a tool. + + Example(s): + tool = RelationshipTool( + name = "Relationship Tool example") + constraints = [ + ("source_tool_feature_schema_id_1", "target_tool_feature_schema_id_1"), + ("source_tool_feature_schema_id_2", "target_tool_feature_schema_id_2") + ] + ) + classification = Classification( + class_type = Classification.Type.TEXT, + instructions = "Classification Example") + tool.add_classification(classification) + + Attributes: + tool: Tool.Type.RELATIONSHIP + name: (str) + required: (bool) + color: (str) + classifications: (list) + schema_id: (str) + feature_schema_id: (str) + attributes: (list) + constraints: (list of [str, str]) + """ + + tool: Type = Tool.Type.RELATIONSHIP + constraints: Optional[List[Tuple[str, str]]] = None + + def __post_init__(self): + super().__post_init__() + if self.tool != Tool.Type.RELATIONSHIP: + raise ValueError("RelationshipTool can only be used with Tool.Type.RELATIONSHIP") + + def asdict(self) -> Dict[str, Any]: + result = super().asdict() + if self.constraints is not None: + result["constraints"] = self.constraints + return result + """ The following 2 functions help to bridge the gap between the step reasoning all other tool ontologies. @@ -188,6 +215,8 @@ def tool_cls_from_type(tool_type: str): tool_cls = map_tool_type_to_tool_cls(tool_type) if tool_cls is not None: return tool_cls + if tool_type == Tool.Type.RELATIONSHIP: + return RelationshipTool return Tool From b37e1868e453f601e9a31f06fce1d00595491547 Mon Sep 17 00:00:00 2001 From: Karol Jamrozy Date: Thu, 29 May 2025 18:58:55 +0200 Subject: [PATCH 3/4] Fixes --- libs/labelbox/src/labelbox/schema/ontology.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/ontology.py b/libs/labelbox/src/labelbox/schema/ontology.py index d5cceef8e..b1f8e6dd8 100644 --- a/libs/labelbox/src/labelbox/schema/ontology.py +++ b/libs/labelbox/src/labelbox/schema/ontology.py @@ -160,15 +160,15 @@ class RelationshipTool(Tool): """ A relationship tool to be added to a Project's ontology. - To instantiate, the "tool" and "name" parameters must - be passed in. + The "tool" parameter is automatically set to Tool.Type.RELATIONSHIP + and doesn't need to be passed during instantiation. The "classifications" parameter holds a list of Classification objects. This can be used to add nested classifications to a tool. Example(s): tool = RelationshipTool( - name = "Relationship Tool example") + name = "Relationship Tool example", constraints = [ ("source_tool_feature_schema_id_1", "target_tool_feature_schema_id_1"), ("source_tool_feature_schema_id_2", "target_tool_feature_schema_id_2") @@ -180,7 +180,7 @@ class RelationshipTool(Tool): tool.add_classification(classification) Attributes: - tool: Tool.Type.RELATIONSHIP + tool: Tool.Type.RELATIONSHIP (automatically set) name: (str) required: (bool) color: (str) @@ -191,13 +191,12 @@ class RelationshipTool(Tool): constraints: (list of [str, str]) """ - tool: Type = Tool.Type.RELATIONSHIP constraints: Optional[List[Tuple[str, str]]] = None def __post_init__(self): + # Ensure tool type is set to RELATIONSHIP + self.tool = Tool.Type.RELATIONSHIP super().__post_init__() - if self.tool != Tool.Type.RELATIONSHIP: - raise ValueError("RelationshipTool can only be used with Tool.Type.RELATIONSHIP") def asdict(self) -> Dict[str, Any]: result = super().asdict() From a379b46bd14bda9217caba0db3f16d64bbae1b4f Mon Sep 17 00:00:00 2001 From: Karol Jamrozy Date: Fri, 30 May 2025 16:20:03 +0200 Subject: [PATCH 4/4] Refactor, add unit tests --- libs/labelbox/src/labelbox/schema/ontology.py | 50 ----- .../schema/tool_building/relationship_tool.py | 85 ++++++++ .../tests/unit/test_unit_relationship_tool.py | 195 ++++++++++++++++++ 3 files changed, 280 insertions(+), 50 deletions(-) create mode 100644 libs/labelbox/src/labelbox/schema/tool_building/relationship_tool.py create mode 100644 libs/labelbox/tests/unit/test_unit_relationship_tool.py diff --git a/libs/labelbox/src/labelbox/schema/ontology.py b/libs/labelbox/src/labelbox/schema/ontology.py index b1f8e6dd8..4d4ce6ef3 100644 --- a/libs/labelbox/src/labelbox/schema/ontology.py +++ b/libs/labelbox/src/labelbox/schema/ontology.py @@ -155,56 +155,6 @@ def add_classification(self, classification: Classification) -> None: ) self.classifications.append(classification) -@dataclass -class RelationshipTool(Tool): - """ - A relationship tool to be added to a Project's ontology. - - The "tool" parameter is automatically set to Tool.Type.RELATIONSHIP - and doesn't need to be passed during instantiation. - - The "classifications" parameter holds a list of Classification objects. - This can be used to add nested classifications to a tool. - - Example(s): - tool = RelationshipTool( - name = "Relationship Tool example", - constraints = [ - ("source_tool_feature_schema_id_1", "target_tool_feature_schema_id_1"), - ("source_tool_feature_schema_id_2", "target_tool_feature_schema_id_2") - ] - ) - classification = Classification( - class_type = Classification.Type.TEXT, - instructions = "Classification Example") - tool.add_classification(classification) - - Attributes: - tool: Tool.Type.RELATIONSHIP (automatically set) - name: (str) - required: (bool) - color: (str) - classifications: (list) - schema_id: (str) - feature_schema_id: (str) - attributes: (list) - constraints: (list of [str, str]) - """ - - constraints: Optional[List[Tuple[str, str]]] = None - - def __post_init__(self): - # Ensure tool type is set to RELATIONSHIP - self.tool = Tool.Type.RELATIONSHIP - super().__post_init__() - - def asdict(self) -> Dict[str, Any]: - result = super().asdict() - if self.constraints is not None: - result["constraints"] = self.constraints - return result - - """ The following 2 functions help to bridge the gap between the step reasoning all other tool ontologies. """ diff --git a/libs/labelbox/src/labelbox/schema/tool_building/relationship_tool.py b/libs/labelbox/src/labelbox/schema/tool_building/relationship_tool.py new file mode 100644 index 000000000..93733eea4 --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/tool_building/relationship_tool.py @@ -0,0 +1,85 @@ +# type: ignore + +import uuid +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from labelbox.schema.ontology import Tool + +@dataclass +class RelationshipTool(Tool): + """ + A relationship tool to be added to a Project's ontology. + + The "tool" parameter is automatically set to Tool.Type.RELATIONSHIP + and doesn't need to be passed during instantiation. + + The "classifications" parameter holds a list of Classification objects. + This can be used to add nested classifications to a tool. + + Example(s): + tool = RelationshipTool( + name = "Relationship Tool example", + constraints = [ + ("source_tool_feature_schema_id_1", "target_tool_feature_schema_id_1"), + ("source_tool_feature_schema_id_2", "target_tool_feature_schema_id_2") + ] + ) + classification = Classification( + class_type = Classification.Type.TEXT, + instructions = "Classification Example") + tool.add_classification(classification) + + Attributes: + tool: Tool.Type.RELATIONSHIP (automatically set) + name: (str) + required: (bool) + color: (str) + classifications: (list) + schema_id: (str) + feature_schema_id: (str) + attributes: (list) + constraints: (list of [str, str]) + """ + + constraints: Optional[List[Tuple[str, str]]] = None + + def __init__(self, name: str, constraints: Optional[List[Tuple[str, str]]] = None, **kwargs): + super().__init__(Tool.Type.RELATIONSHIP, name, **kwargs) + if constraints is not None: + self.constraints = constraints + + def __post_init__(self): + # Ensure tool type is set to RELATIONSHIP + self.tool = Tool.Type.RELATIONSHIP + super().__post_init__() + + def asdict(self) -> Dict[str, Any]: + result = super().asdict() + if self.constraints is not None: + result["definition"] = { "constraints": self.constraints } + return result + + def add_constraint(self, start: Tool, end: Tool) -> None: + if self.constraints is None: + self.constraints = [] + + # Ensure feature schema ids are set for the tools, + # the newly set ids will be changed during ontology creation + # but we need to refer to the same ids in the constraints array + # to ensure that the valid constraints are created. + if start.feature_schema_id is None: + start.feature_schema_id = str(uuid.uuid4()) + if start.schema_id is None: + start.schema_id = str(uuid.uuid4()) + if end.feature_schema_id is None: + end.feature_schema_id = str(uuid.uuid4()) + if end.schema_id is None: + end.schema_id = str(uuid.uuid4()) + + self.constraints.append((start.feature_schema_id, end.feature_schema_id)) + + def set_constraints(self, constraints: List[Tuple[Tool, Tool]]) -> None: + self.constraints = [] + for constraint in constraints: + self.add_constraint(constraint[0], constraint[1]) diff --git a/libs/labelbox/tests/unit/test_unit_relationship_tool.py b/libs/labelbox/tests/unit/test_unit_relationship_tool.py new file mode 100644 index 000000000..d18ee80be --- /dev/null +++ b/libs/labelbox/tests/unit/test_unit_relationship_tool.py @@ -0,0 +1,195 @@ +import pytest +import uuid +from unittest.mock import patch + +from labelbox.schema.ontology import Tool +from labelbox.schema.tool_building.relationship_tool import RelationshipTool +from labelbox.schema.tool_building.classification import Classification + + +def test_basic_instantiation(): + tool = RelationshipTool(name="Test Relationship Tool") + + assert tool.name == "Test Relationship Tool" + assert tool.tool == Tool.Type.RELATIONSHIP + assert tool.constraints is None + assert tool.required is False + assert tool.color is None + assert tool.schema_id is None + assert tool.feature_schema_id is None + + +def test_instantiation_with_constraints(): + constraints = [ + ("source_id_1", "target_id_1"), + ("source_id_2", "target_id_2") + ] + tool = RelationshipTool(name="Test Tool", constraints=constraints) + + assert tool.name == "Test Tool" + assert tool.constraints == constraints + assert len(tool.constraints) == 2 + +def test_post_init_sets_tool_type(): + tool = RelationshipTool(name="Test Tool") + assert tool.tool == Tool.Type.RELATIONSHIP + + +def test_asdict_without_constraints(): + tool = RelationshipTool( + name="Test Tool", + required=True, + color="#FF0000" + ) + + result = tool.asdict() + expected = { + "tool": "edge", + "name": "Test Tool", + "required": True, + "color": "#FF0000", + "classifications": [], + "schemaNodeId": None, + "featureSchemaId": None, + "attributes": None + } + + assert result == expected + +def test_asdict_with_constraints(): + constraints = [("source_id", "target_id")] + tool = RelationshipTool(name="Test Tool", constraints=constraints) + + result = tool.asdict() + + assert "definition" in result + assert result["definition"] == {"constraints": constraints} + assert result["tool"] == "edge" + assert result["name"] == "Test Tool" + + +def test_add_constraint_to_empty_constraints(): + tool = RelationshipTool(name="Test Tool") + start_tool = Tool(Tool.Type.BBOX, "Start Tool") + end_tool = Tool(Tool.Type.POLYGON, "End Tool") + + with patch('uuid.uuid4') as mock_uuid: + mock_uuid.return_value.hex = "test-uuid" + tool.add_constraint(start_tool, end_tool) + + assert tool.constraints is not None + assert len(tool.constraints) == 1 + assert start_tool.feature_schema_id is not None + assert start_tool.schema_id is not None + assert end_tool.feature_schema_id is not None + assert end_tool.schema_id is not None + + +def test_add_constraint_to_existing_constraints(): + existing_constraints = [("existing_source", "existing_target")] + tool = RelationshipTool(name="Test Tool", constraints=existing_constraints) + + start_tool = Tool(Tool.Type.BBOX, "Start Tool") + end_tool = Tool(Tool.Type.POLYGON, "End Tool") + + tool.add_constraint(start_tool, end_tool) + + assert len(tool.constraints) == 2 + assert tool.constraints[0] == ("existing_source", "existing_target") + assert tool.constraints[1] == (start_tool.feature_schema_id, end_tool.feature_schema_id) + + +def test_add_constraint_preserves_existing_ids(): + tool = RelationshipTool(name="Test Tool") + start_tool_feature_schema_id = "start_tool_feature_schema_id" + start_tool_schema_id = "start_tool_schema_id" + start_tool = Tool(Tool.Type.BBOX, "Start Tool", feature_schema_id=start_tool_feature_schema_id, schema_id=start_tool_schema_id) + end_tool_feature_schema_id = "end_tool_feature_schema_id" + end_tool_schema_id = "end_tool_schema_id" + end_tool = Tool(Tool.Type.POLYGON, "End Tool", feature_schema_id=end_tool_feature_schema_id, schema_id=end_tool_schema_id) + + tool.add_constraint(start_tool, end_tool) + + assert start_tool.feature_schema_id == start_tool_feature_schema_id + assert start_tool.schema_id == start_tool_schema_id + assert end_tool.feature_schema_id == end_tool_feature_schema_id + assert end_tool.schema_id == end_tool_schema_id + assert tool.constraints == [(start_tool_feature_schema_id, end_tool_feature_schema_id)] + + +def test_set_constraints(): + tool = RelationshipTool(name="Test Tool") + + start_tool1 = Tool(Tool.Type.BBOX, "Start Tool 1") + end_tool1 = Tool(Tool.Type.POLYGON, "End Tool 1") + start_tool2 = Tool(Tool.Type.POINT, "Start Tool 2") + end_tool2 = Tool(Tool.Type.LINE, "End Tool 2") + + tool.set_constraints([ + (start_tool1, end_tool1), + (start_tool2, end_tool2) + ]) + + assert len(tool.constraints) == 2 + assert tool.constraints[0] == (start_tool1.feature_schema_id, end_tool1.feature_schema_id) + assert tool.constraints[1] == (start_tool2.feature_schema_id, end_tool2.feature_schema_id) + + +def test_set_constraints_replaces_existing(): + existing_constraints = [("old_source", "old_target")] + tool = RelationshipTool(name="Test Tool", constraints=existing_constraints) + + start_tool = Tool(Tool.Type.BBOX, "Start Tool") + end_tool = Tool(Tool.Type.POLYGON, "End Tool") + + tool.set_constraints([(start_tool, end_tool)]) + + assert len(tool.constraints) == 1 + assert tool.constraints[0] != ("old_source", "old_target") + assert tool.constraints[0] == (start_tool.feature_schema_id, end_tool.feature_schema_id) + + +def test_uuid_generation_in_add_constraint(): + tool = RelationshipTool(name="Test Tool") + + start_tool = Tool(Tool.Type.BBOX, "Start Tool") + end_tool = Tool(Tool.Type.POLYGON, "End Tool") + + # Ensure tools don't have IDs initially + assert start_tool.feature_schema_id is None + assert start_tool.schema_id is None + assert end_tool.feature_schema_id is None + assert end_tool.schema_id is None + + tool.add_constraint(start_tool, end_tool) + + # Check that UUIDs were generated + assert start_tool.feature_schema_id is not None + assert start_tool.schema_id is not None + assert end_tool.feature_schema_id is not None + assert end_tool.schema_id is not None + + # Check that they are valid UUID strings + uuid.UUID(start_tool.feature_schema_id) # Will raise ValueError if invalid + uuid.UUID(start_tool.schema_id) + uuid.UUID(end_tool.feature_schema_id) + uuid.UUID(end_tool.schema_id) + + +def test_constraints_in_asdict(): + tool = RelationshipTool(name="Test Tool") + + start_tool = Tool(Tool.Type.BBOX, "Start Tool") + end_tool = Tool(Tool.Type.POLYGON, "End Tool") + + tool.add_constraint(start_tool, end_tool) + + result = tool.asdict() + + assert "definition" in result + assert "constraints" in result["definition"] + assert len(result["definition"]["constraints"]) == 1 + assert result["definition"]["constraints"][0] == ( + start_tool.feature_schema_id, + end_tool.feature_schema_id + )