Skip to content

Commit 5a796da

Browse files
authored
Merge pull request #940 from Labelbox/lgluszek/ontology-update
New methods to manage feature schemas and ontologies
2 parents 25cc315 + 6345182 commit 5a796da

File tree

6 files changed

+454
-5
lines changed

6 files changed

+454
-5
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ test-custom: build
4444
-e DA_GCP_LABELBOX_API_KEY=${DA_GCP_LABELBOX_API_KEY} \
4545
-e LABELBOX_TEST_API_KEY_CUSTOM=${LABELBOX_TEST_API_KEY_CUSTOM} \
4646
-e LABELBOX_TEST_GRAPHQL_API_ENDPOINT=${LABELBOX_TEST_GRAPHQL_API_ENDPOINT} \
47+
-e LABELBOX_TEST_REST_API_ENDPOINT=${LABELBOX_TEST_REST_API_ENDPOINT} \
4748
local/labelbox-python:test pytest $(PATH_TO_TEST)

labelbox/client.py

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import mimetypes
99
import os
1010
import time
11+
import urllib.parse
1112

1213
from google.api_core import retry
1314
import requests
@@ -28,7 +29,7 @@
2829
from labelbox.schema.labeling_frontend import LabelingFrontend
2930
from labelbox.schema.model import Model
3031
from labelbox.schema.model_run import ModelRun
31-
from labelbox.schema.ontology import Ontology, Tool, Classification
32+
from labelbox.schema.ontology import Ontology, Tool, Classification, FeatureSchema
3233
from labelbox.schema.organization import Organization
3334
from labelbox.schema.user import User
3435
from labelbox.schema.project import Project
@@ -55,7 +56,8 @@ def __init__(self,
5556
api_key=None,
5657
endpoint='https://api.labelbox.com/graphql',
5758
enable_experimental=False,
58-
app_url="https://app.labelbox.com"):
59+
app_url="https://app.labelbox.com",
60+
rest_endpoint="https://api.labelbox.com/api/v1"):
5961
""" Creates and initializes a Labelbox Client.
6062
6163
Logging is defaulted to level WARNING. To receive more verbose
@@ -88,6 +90,7 @@ def __init__(self,
8890
logger.info("Initializing Labelbox client at '%s'", endpoint)
8991
self.app_url = app_url
9092
self.endpoint = endpoint
93+
self.rest_endpoint = rest_endpoint
9194
self.headers = {
9295
'Accept': 'application/json',
9396
'Content-Type': 'application/json',
@@ -899,6 +902,192 @@ def create_ontology_from_feature_schemas(self,
899902
normalized = {'tools': tools, 'classifications': classifications}
900903
return self.create_ontology(name, normalized, media_type)
901904

905+
def delete_unused_feature_schema(self, feature_schema_id: str) -> None:
906+
"""
907+
Deletes a feature schema if it is not used by any ontologies or annotations
908+
Args:
909+
feature_schema_id (str): The id of the feature schema to delete
910+
Example:
911+
>>> client.delete_unused_feature_schema("cleabc1my012ioqvu5anyaabc")
912+
"""
913+
914+
endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(
915+
feature_schema_id)
916+
response = requests.delete(
917+
endpoint,
918+
headers=self.headers,
919+
)
920+
921+
if response.status_code != requests.codes.no_content:
922+
raise labelbox.exceptions.LabelboxError(
923+
"Failed to delete the feature schema, message: " +
924+
str(response.json()['message']))
925+
926+
def delete_unused_ontology(self, ontology_id: str) -> None:
927+
"""
928+
Deletes an ontology if it is not used by any annotations
929+
Args:
930+
ontology_id (str): The id of the ontology to delete
931+
Example:
932+
>>> client.delete_unused_ontology("cleabc1my012ioqvu5anyaabc")
933+
"""
934+
935+
endpoint = self.rest_endpoint + "/ontologies/" + urllib.parse.quote(
936+
ontology_id)
937+
response = requests.delete(
938+
endpoint,
939+
headers=self.headers,
940+
)
941+
942+
if response.status_code != requests.codes.no_content:
943+
raise labelbox.exceptions.LabelboxError(
944+
"Failed to delete the ontology, message: " +
945+
str(response.json()['message']))
946+
947+
def update_feature_schema_title(self, feature_schema_id: str,
948+
title: str) -> FeatureSchema:
949+
"""
950+
Updates a title of a feature schema
951+
Args:
952+
feature_schema_id (str): The id of the feature schema to update
953+
title (str): The new title of the feature schema
954+
Returns:
955+
The updated feature schema
956+
Example:
957+
>>> client.update_feature_schema_title("cleabc1my012ioqvu5anyaabc", "New Title")
958+
"""
959+
960+
endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(
961+
feature_schema_id) + '/definition'
962+
response = requests.patch(
963+
endpoint,
964+
headers=self.headers,
965+
json={"title": title},
966+
)
967+
968+
if response.status_code == requests.codes.ok:
969+
return self.get_feature_schema(feature_schema_id)
970+
else:
971+
raise labelbox.exceptions.LabelboxError(
972+
"Failed to update the feature schema, message: " +
973+
str(response.json()['message']))
974+
975+
def upsert_feature_schema(self, feature_schema: Dict) -> FeatureSchema:
976+
"""
977+
Upserts a feature schema
978+
Args:
979+
feature_schema: Dict representing the feature schema to upsert
980+
Returns:
981+
The upserted feature schema
982+
Example:
983+
Insert a new feature schema
984+
>>> tool = Tool(name="tool", tool=Tool.Type.BOUNDING_BOX, color="#FF0000")
985+
>>> client.upsert_feature_schema(tool.asdict())
986+
Update an existing feature schema
987+
>>> tool = Tool(feature_schema_id="cleabc1my012ioqvu5anyaabc", name="tool", tool=Tool.Type.BOUNDING_BOX, color="#FF0000")
988+
>>> client.upsert_feature_schema(tool.asdict())
989+
"""
990+
991+
feature_schema_id = feature_schema.get(
992+
"featureSchemaId") or "new_feature_schema_id"
993+
endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(
994+
feature_schema_id)
995+
response = requests.put(
996+
endpoint,
997+
headers=self.headers,
998+
json={"normalized": json.dumps(feature_schema)},
999+
)
1000+
1001+
if response.status_code == requests.codes.ok:
1002+
return self.get_feature_schema(response.json()['schemaId'])
1003+
else:
1004+
raise labelbox.exceptions.LabelboxError(
1005+
"Failed to upsert the feature schema, message: " +
1006+
str(response.json()['message']))
1007+
1008+
def insert_feature_schema_into_ontology(self, feature_schema_id: str,
1009+
ontology_id: str,
1010+
position: int) -> None:
1011+
"""
1012+
Inserts a feature schema into an ontology. If the feature schema is already in the ontology,
1013+
it will be moved to the new position.
1014+
Args:
1015+
feature_schema_id (str): The feature schema id to upsert
1016+
ontology_id (str): The id of the ontology to insert the feature schema into
1017+
position (int): The position number of the feature schema in the ontology
1018+
Example:
1019+
>>> client.insert_feature_schema_into_ontology("cleabc1my012ioqvu5anyaabc", "clefdvwl7abcgefgu3lyvcde", 2)
1020+
"""
1021+
1022+
endpoint = self.rest_endpoint + '/ontologies/' + urllib.parse.quote(
1023+
ontology_id) + "/feature-schemas/" + urllib.parse.quote(
1024+
feature_schema_id)
1025+
response = requests.post(
1026+
endpoint,
1027+
headers=self.headers,
1028+
json={"position": position},
1029+
)
1030+
if response.status_code != requests.codes.created:
1031+
raise labelbox.exceptions.LabelboxError(
1032+
"Failed to insert the feature schema into the ontology, message: "
1033+
+ str(response.json()['message']))
1034+
1035+
def get_unused_ontologies(self, after: str = None) -> List[str]:
1036+
"""
1037+
Returns a list of unused ontology ids
1038+
Args:
1039+
after (str): The cursor to use for pagination
1040+
Returns:
1041+
A list of unused ontology ids
1042+
Example:
1043+
To get the first page of unused ontology ids (100 at a time)
1044+
>>> client.get_unused_ontologies()
1045+
To get the next page of unused ontology ids
1046+
>>> client.get_unused_ontologies("cleabc1my012ioqvu5anyaabc")
1047+
"""
1048+
1049+
endpoint = self.rest_endpoint + "/ontologies/unused"
1050+
response = requests.get(
1051+
endpoint,
1052+
headers=self.headers,
1053+
json={"after": after},
1054+
)
1055+
1056+
if response.status_code == requests.codes.ok:
1057+
return response.json()
1058+
else:
1059+
raise labelbox.exceptions.LabelboxError(
1060+
"Failed to get unused ontologies, message: " +
1061+
str(response.json()['message']))
1062+
1063+
def get_unused_feature_schemas(self, after: str = None) -> List[str]:
1064+
"""
1065+
Returns a list of unused feature schema ids
1066+
Args:
1067+
after (str): The cursor to use for pagination
1068+
Returns:
1069+
A list of unused feature schema ids
1070+
Example:
1071+
To get the first page of unused feature schema ids (100 at a time)
1072+
>>> client.get_unused_feature_schemas()
1073+
To get the next page of unused feature schema ids
1074+
>>> client.get_unused_feature_schemas("cleabc1my012ioqvu5anyaabc")
1075+
"""
1076+
1077+
endpoint = self.rest_endpoint + "/feature-schemas/unused"
1078+
response = requests.get(
1079+
endpoint,
1080+
headers=self.headers,
1081+
json={"after": after},
1082+
)
1083+
1084+
if response.status_code == requests.codes.ok:
1085+
return response.json()
1086+
else:
1087+
raise labelbox.exceptions.LabelboxError(
1088+
"Failed to get unused feature schemas, message: " +
1089+
str(response.json()['message']))
1090+
9021091
def create_ontology(self, name, normalized, media_type=None) -> Ontology:
9031092
"""
9041093
Creates an ontology from normalized data

tests/integration/conftest.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ def graphql_url(environ: str) -> str:
6565
return 'http://host.docker.internal:8080/graphql'
6666

6767

68+
def rest_url(environ: str) -> str:
69+
if environ == Environ.PROD:
70+
return 'https://api.labelbox.com/api/v1'
71+
elif environ == Environ.STAGING:
72+
return 'https://api.lb-stage.xyz/api/v1'
73+
elif environ == Environ.CUSTOM:
74+
rest_api_endpoint = os.environ.get('LABELBOX_TEST_REST_API_ENDPOINT')
75+
if rest_api_endpoint is None:
76+
raise Exception(f"Missing LABELBOX_TEST_REST_API_ENDPOINT")
77+
return rest_api_endpoint
78+
return 'http://host.docker.internal:8080/api/v1'
79+
80+
6881
def testing_api_key(environ: str) -> str:
6982
if environ == Environ.PROD:
7083
return os.environ["LABELBOX_TEST_API_KEY_PROD"]
@@ -131,7 +144,11 @@ class IntegrationClient(Client):
131144
def __init__(self, environ: str) -> None:
132145
api_url = graphql_url(environ)
133146
api_key = testing_api_key(environ)
134-
super().__init__(api_key, api_url, enable_experimental=True)
147+
rest_endpoint = rest_url(environ)
148+
super().__init__(api_key,
149+
api_url,
150+
enable_experimental=True,
151+
rest_endpoint=rest_endpoint)
135152
self.queries = []
136153

137154
def execute(self, query=None, params=None, check_naming=True, **kwargs):
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import pytest
2+
3+
from labelbox import Tool, MediaType
4+
5+
point = Tool(
6+
tool=Tool.Type.POINT,
7+
name="name",
8+
color="#ff0000",
9+
)
10+
11+
12+
def test_deletes_a_feature_schema(client):
13+
tool = client.upsert_feature_schema(point.asdict())
14+
15+
assert client.delete_unused_feature_schema(
16+
tool.normalized['featureSchemaId']) is None
17+
18+
19+
def test_cant_delete_already_deleted_feature_schema(client):
20+
tool = client.upsert_feature_schema(point.asdict())
21+
feature_schema_id = tool.normalized['featureSchemaId']
22+
23+
client.delete_unused_feature_schema(feature_schema_id) is None
24+
25+
with pytest.raises(
26+
Exception,
27+
match=
28+
"Failed to delete the feature schema, message: Feature schema is already deleted"
29+
):
30+
client.delete_unused_feature_schema(feature_schema_id)
31+
32+
33+
def test_cant_delete_feature_schema_with_ontology(client):
34+
tool = client.upsert_feature_schema(point.asdict())
35+
feature_schema_id = tool.normalized['featureSchemaId']
36+
ontology = client.create_ontology_from_feature_schemas(
37+
name='ontology name',
38+
feature_schema_ids=[feature_schema_id],
39+
media_type=MediaType.Image)
40+
41+
with pytest.raises(
42+
Exception,
43+
match=
44+
"Failed to delete the feature schema, message: Feature schema cannot be deleted because it is used in ontologies"
45+
):
46+
client.delete_unused_feature_schema(feature_schema_id)
47+
48+
client.delete_unused_ontology(ontology.uid)
49+
client.delete_unused_feature_schema(feature_schema_id)
50+
51+
52+
def test_throws_an_error_if_feature_schema_to_delete_doesnt_exist(client):
53+
with pytest.raises(
54+
Exception,
55+
match=
56+
"Failed to delete the feature schema, message: Cannot find root schema node with feature schema id doesntexist"
57+
):
58+
client.delete_unused_feature_schema("doesntexist")
59+
60+
61+
def test_updates_a_feature_schema_title(client):
62+
tool = client.upsert_feature_schema(point.asdict())
63+
feature_schema_id = tool.normalized['featureSchemaId']
64+
new_title = "new title"
65+
updated_feature_schema = client.update_feature_schema_title(
66+
feature_schema_id, new_title)
67+
68+
assert updated_feature_schema.normalized['name'] == new_title
69+
70+
client.delete_unused_feature_schema(feature_schema_id)
71+
72+
73+
def test_throws_an_error_when_updating_a_feature_schema_with_empty_title(
74+
client):
75+
tool = client.upsert_feature_schema(point.asdict())
76+
feature_schema_id = tool.normalized['featureSchemaId']
77+
78+
with pytest.raises(Exception):
79+
client.update_feature_schema_title(feature_schema_id, "")
80+
81+
client.delete_unused_feature_schema(feature_schema_id)
82+
83+
84+
def test_throws_an_error_when_updating_not_existing_feature_schema(client):
85+
with pytest.raises(Exception):
86+
client.update_feature_schema_title("doesntexist", "new title")
87+
88+
89+
def test_creates_a_new_feature_schema(client):
90+
created_feature_schema = client.upsert_feature_schema(point.asdict())
91+
92+
assert created_feature_schema.uid is not None
93+
94+
client.delete_unused_feature_schema(
95+
created_feature_schema.normalized['featureSchemaId'])
96+
97+
98+
def test_updates_a_feature_schema(client):
99+
tool = Tool(
100+
tool=Tool.Type.POINT,
101+
name="name",
102+
color="#ff0000",
103+
)
104+
created_feature_schema = client.upsert_feature_schema(tool.asdict())
105+
tool_to_update = Tool(
106+
tool=Tool.Type.POINT,
107+
name="new name",
108+
color="#ff0000",
109+
feature_schema_id=created_feature_schema.normalized['featureSchemaId'],
110+
)
111+
updated_feature_schema = client.upsert_feature_schema(
112+
tool_to_update.asdict())
113+
114+
assert updated_feature_schema.normalized['name'] == "new name"
115+
116+
117+
def test_does_not_include_used_feature_schema(client):
118+
tool = client.upsert_feature_schema(point.asdict())
119+
feature_schema_id = tool.normalized['featureSchemaId']
120+
ontology = client.create_ontology_from_feature_schemas(
121+
name='ontology name',
122+
feature_schema_ids=[feature_schema_id],
123+
media_type=MediaType.Image)
124+
unused_feature_schemas = client.get_unused_feature_schemas()
125+
126+
assert feature_schema_id not in unused_feature_schemas
127+
128+
client.delete_unused_ontology(ontology.uid)
129+
client.delete_unused_feature_schema(feature_schema_id)

0 commit comments

Comments
 (0)