From f0f8467f84dacd59eb27b8fc1c97ae340812a962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Mon, 27 Feb 2023 13:41:51 +0100 Subject: [PATCH 1/8] add methods to manage feature schemas --- Makefile | 1 + labelbox/client.py | 128 ++++++++++++++++++++++- pytest.ini | 2 +- tests/integration/conftest.py | 17 ++- tests/integration/test_feature_schema.py | 101 ++++++++++++++++++ tests/integration/test_ontology.py | 106 ++++++++++++++++++- tox.ini | 3 +- 7 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 tests/integration/test_feature_schema.py diff --git a/Makefile b/Makefile index 344030301..b8d2729ec 100644 --- a/Makefile +++ b/Makefile @@ -44,4 +44,5 @@ test-custom: build -e DA_GCP_LABELBOX_API_KEY=${DA_GCP_LABELBOX_API_KEY} \ -e LABELBOX_TEST_API_KEY_CUSTOM=${LABELBOX_TEST_API_KEY_CUSTOM} \ -e LABELBOX_TEST_GRAPHQL_API_ENDPOINT=${LABELBOX_TEST_GRAPHQL_API_ENDPOINT} \ + -e LABELBOX_TEST_REST_API_ENDPOINT=${LABELBOX_TEST_REST_API_ENDPOINT} \ local/labelbox-python:test pytest $(PATH_TO_TEST) diff --git a/labelbox/client.py b/labelbox/client.py index 172184608..9159d0015 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -8,6 +8,7 @@ import mimetypes import os import time +import urllib.parse from google.api_core import retry import requests @@ -55,7 +56,8 @@ def __init__(self, api_key=None, endpoint='https://api.labelbox.com/graphql', enable_experimental=False, - app_url="https://app.labelbox.com"): + app_url="https://app.labelbox.com", + rest_endpoint="https://api.labelbox.com/api/v1"): """ Creates and initializes a Labelbox Client. Logging is defaulted to level WARNING. To receive more verbose @@ -88,6 +90,7 @@ def __init__(self, logger.info("Initializing Labelbox client at '%s'", endpoint) self.app_url = app_url self.endpoint = endpoint + self.rest_endpoint = rest_endpoint self.headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', @@ -899,6 +902,129 @@ def create_ontology_from_feature_schemas(self, normalized = {'tools': tools, 'classifications': classifications} return self.create_ontology(name, normalized, media_type) + def delete_unused_feature_schema(self, feature_schema_id: str): + """ + Deletes a feature schema if it is not used by any ontologies or annotations + Args: + feature_schema_id (str): The id of the feature schema to delete + Example: + >>> client.delete_unused_feature_schema("cleabc1my012ioqvu5anyaabc") + """ + + endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(feature_schema_id) + response = requests.delete( + endpoint, + headers=self.headers, + ) + + if response.status_code != requests.codes.no_content: + raise labelbox.exceptions.LabelboxError( + "Failed to delete the feature schema, message: " + + str(response.json()['message'])) + + def delete_unused_ontology(self, ontology_id: str): + """ + Deletes an ontology if it is not used by any annotations + Args: + ontology_id (str): The id of the ontology to delete + Example: + >>> client.delete_unused_ontology("cleabc1my012ioqvu5anyaabc") + """ + + endpoint = self.rest_endpoint + "/ontologies/" + urllib.parse.quote(ontology_id) + response = requests.delete( + endpoint, + headers=self.headers, + ) + + if response.status_code != requests.codes.no_content: + raise labelbox.exceptions.LabelboxError( + "Failed to delete the ontology, message: " + + str(response.json()['message'])) + + def update_feature_schema_title(self, feature_schema_id: str, title: str): + """ + Updates a title of a feature schema + Args: + feature_schema_id (str): The id of the feature schema to update + title (str): The new title of the feature schema + Returns: + The updated feature schema + Example: + >>> client.update_feature_schema_title("cleabc1my012ioqvu5anyaabc", "New Title") + """ + + endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(feature_schema_id) + '/definition' + response = requests.patch( + endpoint, + headers=self.headers, + json={"title": title}, + ) + + if response.status_code == requests.codes.ok: + return self.get_feature_schema(feature_schema_id) + else: + raise labelbox.exceptions.LabelboxError( + "Failed to update the feature schema, message: " + + str(response.json()['message'])) + + def upsert_feature_schema(self, normalized: Dict): + """ + Upserts a feature schema + Args: + normalized: The feature schema to upsert + Returns: + The upserted feature schema + Example: + Insert a new feature schema + >>> tool = Tool(name="tool", tool=Tool.Type.BOUNDING_BOX, color="#FF0000") + >>> client.upsert_feature_schema(tool.asdict()) + Update an existing feature schema + >>> tool = Tool(feature_schema_id="cleabc1my012ioqvu5anyaabc", name="tool", tool=Tool.Type.BOUNDING_BOX, color="#FF0000") + >>> client.upsert_feature_schema(tool.asdict()) + """ + + feature_schema_id = normalized.get( + "featureSchemaId") or "new_feature_schema_id" + endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote( + feature_schema_id) + response = requests.put( + endpoint, + headers=self.headers, + json={"normalized": json.dumps(normalized)}, + ) + + if response.status_code == requests.codes.ok: + return self.get_feature_schema(response.json()['schemaId']) + else: + raise labelbox.exceptions.LabelboxError( + "Failed to upsert the feature schema, message: " + + str(response.json()['message'])) + + def insert_feature_schema_into_ontology(self, feature_schema_id: str, ontology_id: str, position: int): + """ + Inserts a feature schema into an ontology. If the feature schema is already in the ontology, + it will be moved to the new position. + Args: + feature_schema_id (str): The feature schema id to upsert + ontology_id (str): The id of the ontology to insert the feature schema into + position (int): The position number of the feature schema in the ontology + Example: + >>> client.insert_feature_schema_into_ontology("cleabc1my012ioqvu5anyaabc", "clefdvwl7abcgefgu3lyvcde", 2) + """ + + endpoint = self.rest_endpoint + '/ontologies/' + urllib.parse.quote( + ontology_id) + "/feature-schemas/" + urllib.parse.quote(feature_schema_id) + response = requests.post( + endpoint, + headers=self.headers, + json={"position": position}, + ) + if response.status_code != requests.codes.created: + raise labelbox.exceptions.LabelboxError( + "Failed to insert the feature schema into the ontology, message: " + + str(response.json()['message'])) + def create_ontology(self, name, normalized, media_type=None) -> Ontology: """ Creates an ontology from normalized data diff --git a/pytest.ini b/pytest.ini index b56afefdd..8b3a70089 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] -addopts = -s -vv --reruns 5 --reruns-delay 10 --durations=20 +addopts = -s -vv --reruns 1 --reruns-delay 10 --durations=20 markers = slow: marks tests as slow (deselect with '-m "not slow"') diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index fe0e387d0..93195dd23 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -65,6 +65,20 @@ def graphql_url(environ: str) -> str: return 'http://host.docker.internal:8080/graphql' +def rest_url(environ: str) -> str: + if environ == Environ.PROD: + return 'https://api.labelbox.com/api/v1' + elif environ == Environ.STAGING: + return 'https://api.lb-stage.xyz/api/v1' + elif environ == Environ.CUSTOM: + graphql_api_endpoint = os.environ.get( + 'LABELBOX_TEST_REST_API_ENDPOINT') + if graphql_api_endpoint is None: + raise Exception(f"Missing LABELBOX_TEST_REST_API_ENDPOINT") + return graphql_api_endpoint + return 'http://host.docker.internal:8080/api/v1' + + def testing_api_key(environ: str) -> str: if environ == Environ.PROD: return os.environ["LABELBOX_TEST_API_KEY_PROD"] @@ -131,7 +145,8 @@ class IntegrationClient(Client): def __init__(self, environ: str) -> None: api_url = graphql_url(environ) api_key = testing_api_key(environ) - super().__init__(api_key, api_url, enable_experimental=True) + rest_endpoint = rest_url(environ) + super().__init__(api_key, api_url, enable_experimental=True, rest_endpoint=rest_endpoint) self.queries = [] def execute(self, query=None, params=None, check_naming=True, **kwargs): diff --git a/tests/integration/test_feature_schema.py b/tests/integration/test_feature_schema.py new file mode 100644 index 000000000..4910afafe --- /dev/null +++ b/tests/integration/test_feature_schema.py @@ -0,0 +1,101 @@ +import pytest + +from labelbox import Tool, MediaType + +point = Tool( + tool=Tool.Type.POINT, + name="name", + color="#ff0000", +) + + +def test_deletes_a_feature_schema(client): + tool = client.upsert_feature_schema(point.asdict()) + + assert client.delete_unused_feature_schema(tool.normalized['featureSchemaId']) is None + + +def test_cant_delete_already_deleted_feature_schema(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + + client.delete_unused_feature_schema(feature_schema_id) is None + + with pytest.raises(Exception, + match="Failed to delete the feature schema, message: Feature schema is already deleted"): + client.delete_unused_feature_schema(feature_schema_id) + + +def test_cant_delete_feature_schema_with_ontology(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + ontology = client.create_ontology_from_feature_schemas( + name='ontology name', + feature_schema_ids=[feature_schema_id], + media_type=MediaType.Image) + + with pytest.raises(Exception, + match="Failed to delete the feature schema, message: Feature schema cannot be deleted because it is used in ontologies"): + client.delete_unused_feature_schema(feature_schema_id) + + client.delete_unused_ontology(ontology.uid) + client.delete_unused_feature_schema(feature_schema_id) + + +def test_throws_an_error_if_feature_schema_to_delete_doesnt_exist(client): + with pytest.raises(Exception, + match="Failed to delete the feature schema, message: Cannot find root schema node with feature schema id doesntexist"): + client.delete_unused_feature_schema("doesntexist") + + +def test_updates_a_feature_schema_title(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + new_title = "new title" + updated_feature_schema = client.update_feature_schema_title(feature_schema_id, new_title) + + assert updated_feature_schema.normalized['name'] == new_title + + client.delete_unused_feature_schema(feature_schema_id) + + +def test_throws_an_error_when_updating_a_feature_schema_with_empty_title(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + + with pytest.raises(Exception): + client.update_feature_schema_title(feature_schema_id, "") + + client.delete_unused_feature_schema(feature_schema_id) + + +def test_throws_an_error_when_updating_not_existing_feature_schema(client): + with pytest.raises(Exception): + client.update_feature_schema_title("doesntexist", "new title") + + +def test_creates_a_new_feature_schema(client): + created_feature_schema = client.upsert_feature_schema(point.asdict()) + + assert created_feature_schema.uid is not None + + client.delete_unused_feature_schema(created_feature_schema.normalized['featureSchemaId']) + + +def test_updates_a_feature_schema(client): + tool = Tool( + tool=Tool.Type.POINT, + name="name", + color="#ff0000", + ) + created_feature_schema = client.upsert_feature_schema(tool.asdict()) + tool_to_update = Tool( + tool=Tool.Type.POINT, + name="new name", + color="#ff0000", + feature_schema_id=created_feature_schema.normalized['featureSchemaId'], + ) + updated_feature_schema = client.upsert_feature_schema(tool_to_update.asdict()) + + assert updated_feature_schema.normalized[ + 'name'] == "new name" diff --git a/tests/integration/test_ontology.py b/tests/integration/test_ontology.py index 3b7a7643a..89d500a15 100644 --- a/tests/integration/test_ontology.py +++ b/tests/integration/test_ontology.py @@ -1,20 +1,118 @@ import pytest -from labelbox import OntologyBuilder, MediaType +from labelbox import OntologyBuilder, MediaType, Tool from labelbox.orm.model import Entity import json import time @pytest.mark.skip(reason="normalized ontology contains Relationship, " - "which is not finalized yet. introduce this back when" - "Relationship feature is complete and we introduce" - "a Relationship object to the ontology that we can parse") + "which is not finalized yet. introduce this back when" + "Relationship feature is complete and we introduce" + "a Relationship object to the ontology that we can parse") def test_from_project_ontology(project) -> None: o = OntologyBuilder.from_project(project) assert o.asdict() == project.ontology().normalized +point = Tool( + tool=Tool.Type.POINT, + name="name", + color="#ff0000", +) + + +def test_deletes_an_ontology(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + ontology = client.create_ontology_from_feature_schemas( + name='ontology name', + feature_schema_ids=[feature_schema_id], + media_type=MediaType.Image) + + assert client.delete_unused_ontology(ontology.uid) is None + + client.delete_unused_feature_schema(feature_schema_id) + + +def test_cant_delete_an_ontology_with_project(client): + project = client.create_project(name="test project", media_type=MediaType.Image) + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + ontology = client.create_ontology_from_feature_schemas( + name='ontology name', + feature_schema_ids=[feature_schema_id], + media_type=MediaType.Image) + project.setup_editor(ontology) + + with pytest.raises(Exception, + match="Failed to delete the ontology, message: Cannot delete an ontology connected to a project. The ontology is connected to projects: " + project.uid): + client.delete_unused_ontology(ontology.uid) + + project.delete() + client.delete_unused_ontology(ontology.uid) + client.delete_unused_feature_schema(feature_schema_id) + + +def test_cant_delete_an_ontology_that_doesnt_exist(client): + with pytest.raises(Exception, + match="Failed to delete the ontology, message: Failed to find ontology by id: doesntexist"): + client.delete_unused_ontology("doesntexist") + + +def test_inserts_a_feature_schema_at_given_position(client): + tool1 = { + 'tool': 'polygon', + 'name': 'tool1', + 'color': 'blue' + } + tool2 = { + 'tool': 'polygon', + 'name': 'tool2', + 'color': 'blue' + } + ontology_normalized_json = { + "tools": [tool1, tool2], + "classifications": [] + } + ontology = client.create_ontology(name="ontology", + normalized=ontology_normalized_json, + media_type=MediaType.Image) + created_feature_schema = client.upsert_feature_schema(point.asdict()) + client.insert_feature_schema_into_ontology(created_feature_schema.normalized['featureSchemaId'], ontology.uid, 1) + ontology = client.get_ontology(ontology.uid) + + assert ontology.normalized['tools'][1]['schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] + + client.delete_unused_ontology(ontology.uid) + + +def test_moves_already_added_feature_schema_in_ontology(client): + tool1 = { + 'tool': 'polygon', + 'name': 'tool1', + 'color': 'blue' + } + ontology_normalized_json = { + "tools": [tool1], + "classifications": [] + } + ontology = client.create_ontology(name="ontology", + normalized=ontology_normalized_json, + media_type=MediaType.Image) + created_feature_schema = client.upsert_feature_schema(point.asdict()) + feature_schema_id = created_feature_schema.normalized['featureSchemaId'] + client.insert_feature_schema_into_ontology(feature_schema_id, ontology.uid, 1) + ontology = client.get_ontology(ontology.uid) + assert ontology.normalized['tools'][1]['schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] + client.insert_feature_schema_into_ontology(feature_schema_id, ontology.uid, 0) + ontology = client.get_ontology(ontology.uid) + + assert ontology.normalized['tools'][0]['schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] + + client.delete_unused_ontology(ontology.uid) + + def _get_attr_stringify_json(obj, attr): value = getattr(obj, attr.name) if attr.field_type.name.lower() == "json": diff --git a/tox.ini b/tox.ini index bb124478a..b98fd7fa8 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,8 @@ passenv = LABELBOX_TEST_API_KEY_LOCAL LABELBOX_TEST_API_KEY_ONPREM LABELBOX_TEST_API_KEY_CUSTOM - LABELBOX_TEST_GRAPHQL_API_ENDPOINT + LABELBOX_TEST_GRAPHQL_API_ENDPOINT + LABELBOX_TEST_REST_API_ENDPOINT DA_GCP_LABELBOX_API_KEY commands = pytest {posargs} From 3cea973a9bf4ba2908a0b87e8e6aff9aff7947e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Mon, 27 Feb 2023 13:43:42 +0100 Subject: [PATCH 2/8] formatting --- labelbox/client.py | 19 ++++--- tests/integration/conftest.py | 8 +-- tests/integration/test_feature_schema.py | 39 +++++++++----- tests/integration/test_ontology.py | 69 +++++++++++------------- 4 files changed, 75 insertions(+), 60 deletions(-) diff --git a/labelbox/client.py b/labelbox/client.py index 9159d0015..4a0b97151 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -911,7 +911,8 @@ def delete_unused_feature_schema(self, feature_schema_id: str): >>> client.delete_unused_feature_schema("cleabc1my012ioqvu5anyaabc") """ - endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(feature_schema_id) + endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote( + feature_schema_id) response = requests.delete( endpoint, headers=self.headers, @@ -931,7 +932,8 @@ def delete_unused_ontology(self, ontology_id: str): >>> client.delete_unused_ontology("cleabc1my012ioqvu5anyaabc") """ - endpoint = self.rest_endpoint + "/ontologies/" + urllib.parse.quote(ontology_id) + endpoint = self.rest_endpoint + "/ontologies/" + urllib.parse.quote( + ontology_id) response = requests.delete( endpoint, headers=self.headers, @@ -954,7 +956,8 @@ def update_feature_schema_title(self, feature_schema_id: str, title: str): >>> client.update_feature_schema_title("cleabc1my012ioqvu5anyaabc", "New Title") """ - endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote(feature_schema_id) + '/definition' + endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote( + feature_schema_id) + '/definition' response = requests.patch( endpoint, headers=self.headers, @@ -1001,7 +1004,8 @@ def upsert_feature_schema(self, normalized: Dict): "Failed to upsert the feature schema, message: " + str(response.json()['message'])) - def insert_feature_schema_into_ontology(self, feature_schema_id: str, ontology_id: str, position: int): + def insert_feature_schema_into_ontology(self, feature_schema_id: str, + ontology_id: str, position: int): """ Inserts a feature schema into an ontology. If the feature schema is already in the ontology, it will be moved to the new position. @@ -1014,7 +1018,8 @@ def insert_feature_schema_into_ontology(self, feature_schema_id: str, ontology_i """ endpoint = self.rest_endpoint + '/ontologies/' + urllib.parse.quote( - ontology_id) + "/feature-schemas/" + urllib.parse.quote(feature_schema_id) + ontology_id) + "/feature-schemas/" + urllib.parse.quote( + feature_schema_id) response = requests.post( endpoint, headers=self.headers, @@ -1022,8 +1027,8 @@ def insert_feature_schema_into_ontology(self, feature_schema_id: str, ontology_i ) if response.status_code != requests.codes.created: raise labelbox.exceptions.LabelboxError( - "Failed to insert the feature schema into the ontology, message: " + - str(response.json()['message'])) + "Failed to insert the feature schema into the ontology, message: " + + str(response.json()['message'])) def create_ontology(self, name, normalized, media_type=None) -> Ontology: """ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 93195dd23..25a73d1e2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -71,8 +71,7 @@ def rest_url(environ: str) -> str: elif environ == Environ.STAGING: return 'https://api.lb-stage.xyz/api/v1' elif environ == Environ.CUSTOM: - graphql_api_endpoint = os.environ.get( - 'LABELBOX_TEST_REST_API_ENDPOINT') + graphql_api_endpoint = os.environ.get('LABELBOX_TEST_REST_API_ENDPOINT') if graphql_api_endpoint is None: raise Exception(f"Missing LABELBOX_TEST_REST_API_ENDPOINT") return graphql_api_endpoint @@ -146,7 +145,10 @@ def __init__(self, environ: str) -> None: api_url = graphql_url(environ) api_key = testing_api_key(environ) rest_endpoint = rest_url(environ) - super().__init__(api_key, api_url, enable_experimental=True, rest_endpoint=rest_endpoint) + super().__init__(api_key, + api_url, + enable_experimental=True, + rest_endpoint=rest_endpoint) self.queries = [] def execute(self, query=None, params=None, check_naming=True, **kwargs): diff --git a/tests/integration/test_feature_schema.py b/tests/integration/test_feature_schema.py index 4910afafe..2e640ce06 100644 --- a/tests/integration/test_feature_schema.py +++ b/tests/integration/test_feature_schema.py @@ -12,7 +12,8 @@ def test_deletes_a_feature_schema(client): tool = client.upsert_feature_schema(point.asdict()) - assert client.delete_unused_feature_schema(tool.normalized['featureSchemaId']) is None + assert client.delete_unused_feature_schema( + tool.normalized['featureSchemaId']) is None def test_cant_delete_already_deleted_feature_schema(client): @@ -21,8 +22,11 @@ def test_cant_delete_already_deleted_feature_schema(client): client.delete_unused_feature_schema(feature_schema_id) is None - with pytest.raises(Exception, - match="Failed to delete the feature schema, message: Feature schema is already deleted"): + with pytest.raises( + Exception, + match= + "Failed to delete the feature schema, message: Feature schema is already deleted" + ): client.delete_unused_feature_schema(feature_schema_id) @@ -34,8 +38,11 @@ def test_cant_delete_feature_schema_with_ontology(client): feature_schema_ids=[feature_schema_id], media_type=MediaType.Image) - with pytest.raises(Exception, - match="Failed to delete the feature schema, message: Feature schema cannot be deleted because it is used in ontologies"): + with pytest.raises( + Exception, + match= + "Failed to delete the feature schema, message: Feature schema cannot be deleted because it is used in ontologies" + ): client.delete_unused_feature_schema(feature_schema_id) client.delete_unused_ontology(ontology.uid) @@ -43,8 +50,11 @@ def test_cant_delete_feature_schema_with_ontology(client): def test_throws_an_error_if_feature_schema_to_delete_doesnt_exist(client): - with pytest.raises(Exception, - match="Failed to delete the feature schema, message: Cannot find root schema node with feature schema id doesntexist"): + with pytest.raises( + Exception, + match= + "Failed to delete the feature schema, message: Cannot find root schema node with feature schema id doesntexist" + ): client.delete_unused_feature_schema("doesntexist") @@ -52,14 +62,16 @@ def test_updates_a_feature_schema_title(client): tool = client.upsert_feature_schema(point.asdict()) feature_schema_id = tool.normalized['featureSchemaId'] new_title = "new title" - updated_feature_schema = client.update_feature_schema_title(feature_schema_id, new_title) + updated_feature_schema = client.update_feature_schema_title( + feature_schema_id, new_title) assert updated_feature_schema.normalized['name'] == new_title client.delete_unused_feature_schema(feature_schema_id) -def test_throws_an_error_when_updating_a_feature_schema_with_empty_title(client): +def test_throws_an_error_when_updating_a_feature_schema_with_empty_title( + client): tool = client.upsert_feature_schema(point.asdict()) feature_schema_id = tool.normalized['featureSchemaId'] @@ -79,7 +91,8 @@ def test_creates_a_new_feature_schema(client): assert created_feature_schema.uid is not None - client.delete_unused_feature_schema(created_feature_schema.normalized['featureSchemaId']) + client.delete_unused_feature_schema( + created_feature_schema.normalized['featureSchemaId']) def test_updates_a_feature_schema(client): @@ -95,7 +108,7 @@ def test_updates_a_feature_schema(client): color="#ff0000", feature_schema_id=created_feature_schema.normalized['featureSchemaId'], ) - updated_feature_schema = client.upsert_feature_schema(tool_to_update.asdict()) + updated_feature_schema = client.upsert_feature_schema( + tool_to_update.asdict()) - assert updated_feature_schema.normalized[ - 'name'] == "new name" + assert updated_feature_schema.normalized['name'] == "new name" diff --git a/tests/integration/test_ontology.py b/tests/integration/test_ontology.py index 89d500a15..e7983b55d 100644 --- a/tests/integration/test_ontology.py +++ b/tests/integration/test_ontology.py @@ -7,9 +7,9 @@ @pytest.mark.skip(reason="normalized ontology contains Relationship, " - "which is not finalized yet. introduce this back when" - "Relationship feature is complete and we introduce" - "a Relationship object to the ontology that we can parse") + "which is not finalized yet. introduce this back when" + "Relationship feature is complete and we introduce" + "a Relationship object to the ontology that we can parse") def test_from_project_ontology(project) -> None: o = OntologyBuilder.from_project(project) assert o.asdict() == project.ontology().normalized @@ -36,7 +36,8 @@ def test_deletes_an_ontology(client): def test_cant_delete_an_ontology_with_project(client): - project = client.create_project(name="test project", media_type=MediaType.Image) + project = client.create_project(name="test project", + media_type=MediaType.Image) tool = client.upsert_feature_schema(point.asdict()) feature_schema_id = tool.normalized['featureSchemaId'] ontology = client.create_ontology_from_feature_schemas( @@ -45,8 +46,11 @@ def test_cant_delete_an_ontology_with_project(client): media_type=MediaType.Image) project.setup_editor(ontology) - with pytest.raises(Exception, - match="Failed to delete the ontology, message: Cannot delete an ontology connected to a project. The ontology is connected to projects: " + project.uid): + with pytest.raises( + Exception, + match= + "Failed to delete the ontology, message: Cannot delete an ontology connected to a project. The ontology is connected to projects: " + + project.uid): client.delete_unused_ontology(ontology.uid) project.delete() @@ -55,60 +59,51 @@ def test_cant_delete_an_ontology_with_project(client): def test_cant_delete_an_ontology_that_doesnt_exist(client): - with pytest.raises(Exception, - match="Failed to delete the ontology, message: Failed to find ontology by id: doesntexist"): + with pytest.raises( + Exception, + match= + "Failed to delete the ontology, message: Failed to find ontology by id: doesntexist" + ): client.delete_unused_ontology("doesntexist") def test_inserts_a_feature_schema_at_given_position(client): - tool1 = { - 'tool': 'polygon', - 'name': 'tool1', - 'color': 'blue' - } - tool2 = { - 'tool': 'polygon', - 'name': 'tool2', - 'color': 'blue' - } - ontology_normalized_json = { - "tools": [tool1, tool2], - "classifications": [] - } + tool1 = {'tool': 'polygon', 'name': 'tool1', 'color': 'blue'} + tool2 = {'tool': 'polygon', 'name': 'tool2', 'color': 'blue'} + ontology_normalized_json = {"tools": [tool1, tool2], "classifications": []} ontology = client.create_ontology(name="ontology", normalized=ontology_normalized_json, media_type=MediaType.Image) created_feature_schema = client.upsert_feature_schema(point.asdict()) - client.insert_feature_schema_into_ontology(created_feature_schema.normalized['featureSchemaId'], ontology.uid, 1) + client.insert_feature_schema_into_ontology( + created_feature_schema.normalized['featureSchemaId'], ontology.uid, 1) ontology = client.get_ontology(ontology.uid) - assert ontology.normalized['tools'][1]['schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] + assert ontology.normalized['tools'][1][ + 'schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] client.delete_unused_ontology(ontology.uid) def test_moves_already_added_feature_schema_in_ontology(client): - tool1 = { - 'tool': 'polygon', - 'name': 'tool1', - 'color': 'blue' - } - ontology_normalized_json = { - "tools": [tool1], - "classifications": [] - } + tool1 = {'tool': 'polygon', 'name': 'tool1', 'color': 'blue'} + ontology_normalized_json = {"tools": [tool1], "classifications": []} ontology = client.create_ontology(name="ontology", normalized=ontology_normalized_json, media_type=MediaType.Image) created_feature_schema = client.upsert_feature_schema(point.asdict()) feature_schema_id = created_feature_schema.normalized['featureSchemaId'] - client.insert_feature_schema_into_ontology(feature_schema_id, ontology.uid, 1) + client.insert_feature_schema_into_ontology(feature_schema_id, ontology.uid, + 1) ontology = client.get_ontology(ontology.uid) - assert ontology.normalized['tools'][1]['schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] - client.insert_feature_schema_into_ontology(feature_schema_id, ontology.uid, 0) + assert ontology.normalized['tools'][1][ + 'schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] + client.insert_feature_schema_into_ontology(feature_schema_id, ontology.uid, + 0) ontology = client.get_ontology(ontology.uid) - assert ontology.normalized['tools'][0]['schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] + assert ontology.normalized['tools'][0][ + 'schemaNodeId'] == created_feature_schema.normalized['schemaNodeId'] client.delete_unused_ontology(ontology.uid) From d4faa48ea29be1d8aa11322e6476d529769b0443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Mon, 27 Feb 2023 13:52:24 +0100 Subject: [PATCH 3/8] formatting --- labelbox/client.py | 12 ++++++------ pytest.ini | 2 +- tests/integration/conftest.py | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/labelbox/client.py b/labelbox/client.py index 4a0b97151..4c01d4b95 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -904,12 +904,12 @@ def create_ontology_from_feature_schemas(self, def delete_unused_feature_schema(self, feature_schema_id: str): """ - Deletes a feature schema if it is not used by any ontologies or annotations - Args: - feature_schema_id (str): The id of the feature schema to delete - Example: - >>> client.delete_unused_feature_schema("cleabc1my012ioqvu5anyaabc") - """ + Deletes a feature schema if it is not used by any ontologies or annotations + Args: + feature_schema_id (str): The id of the feature schema to delete + Example: + >>> client.delete_unused_feature_schema("cleabc1my012ioqvu5anyaabc") + """ endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote( feature_schema_id) diff --git a/pytest.ini b/pytest.ini index 8b3a70089..b56afefdd 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] -addopts = -s -vv --reruns 1 --reruns-delay 10 --durations=20 +addopts = -s -vv --reruns 5 --reruns-delay 10 --durations=20 markers = slow: marks tests as slow (deselect with '-m "not slow"') diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 25a73d1e2..854ea1c57 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -71,10 +71,10 @@ def rest_url(environ: str) -> str: elif environ == Environ.STAGING: return 'https://api.lb-stage.xyz/api/v1' elif environ == Environ.CUSTOM: - graphql_api_endpoint = os.environ.get('LABELBOX_TEST_REST_API_ENDPOINT') - if graphql_api_endpoint is None: + rest_api_endpoint = os.environ.get('LABELBOX_TEST_REST_API_ENDPOINT') + if rest_api_endpoint is None: raise Exception(f"Missing LABELBOX_TEST_REST_API_ENDPOINT") - return graphql_api_endpoint + return rest_api_endpoint return 'http://host.docker.internal:8080/api/v1' From 56583b56d164f6e1c3d044414ec91e6fbf46f0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Tue, 28 Feb 2023 12:22:25 +0100 Subject: [PATCH 4/8] rename normalized to feature_schema --- labelbox/client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/labelbox/client.py b/labelbox/client.py index 4c01d4b95..c3577d6e8 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -971,11 +971,11 @@ def update_feature_schema_title(self, feature_schema_id: str, title: str): "Failed to update the feature schema, message: " + str(response.json()['message'])) - def upsert_feature_schema(self, normalized: Dict): + def upsert_feature_schema(self, feature_schema: Dict): """ Upserts a feature schema Args: - normalized: The feature schema to upsert + feature_schema: Dict representing the feature schema to upsert Returns: The upserted feature schema Example: @@ -987,14 +987,14 @@ def upsert_feature_schema(self, normalized: Dict): >>> client.upsert_feature_schema(tool.asdict()) """ - feature_schema_id = normalized.get( + feature_schema_id = feature_schema.get( "featureSchemaId") or "new_feature_schema_id" endpoint = self.rest_endpoint + "/feature-schemas/" + urllib.parse.quote( feature_schema_id) response = requests.put( endpoint, headers=self.headers, - json={"normalized": json.dumps(normalized)}, + json={"normalized": json.dumps(feature_schema)}, ) if response.status_code == requests.codes.ok: From c72829053b3ecaea35bb3b46628a9479bfcfe7b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Tue, 28 Feb 2023 13:26:36 +0100 Subject: [PATCH 5/8] add function return types --- labelbox/client.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/labelbox/client.py b/labelbox/client.py index c3577d6e8..d41782887 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -902,7 +902,7 @@ def create_ontology_from_feature_schemas(self, normalized = {'tools': tools, 'classifications': classifications} return self.create_ontology(name, normalized, media_type) - def delete_unused_feature_schema(self, feature_schema_id: str): + def delete_unused_feature_schema(self, feature_schema_id: str) -> None: """ Deletes a feature schema if it is not used by any ontologies or annotations Args: @@ -923,7 +923,7 @@ def delete_unused_feature_schema(self, feature_schema_id: str): "Failed to delete the feature schema, message: " + str(response.json()['message'])) - def delete_unused_ontology(self, ontology_id: str): + def delete_unused_ontology(self, ontology_id: str) -> None: """ Deletes an ontology if it is not used by any annotations Args: @@ -1005,7 +1005,8 @@ def upsert_feature_schema(self, feature_schema: Dict): str(response.json()['message'])) def insert_feature_schema_into_ontology(self, feature_schema_id: str, - ontology_id: str, position: int): + ontology_id: str, + position: int) -> None: """ Inserts a feature schema into an ontology. If the feature schema is already in the ontology, it will be moved to the new position. From bd8831caf8cc11a66ae9be3094e214b895bbeaaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Tue, 28 Feb 2023 14:44:45 +0100 Subject: [PATCH 6/8] add get_unused_ontologies and get_unused_feature_schemas --- labelbox/client.py | 56 ++++++++++++++++++++++++ tests/integration/test_feature_schema.py | 15 +++++++ tests/integration/test_ontology.py | 19 ++++++++ 3 files changed, 90 insertions(+) diff --git a/labelbox/client.py b/labelbox/client.py index d41782887..993adcc7f 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -1031,6 +1031,62 @@ def insert_feature_schema_into_ontology(self, feature_schema_id: str, "Failed to insert the feature schema into the ontology, message: " + str(response.json()['message'])) + def get_unused_ontologies(self, after: str = None) -> List[str]: + """ + Returns a list of unused ontology ids + Args: + after (str): The cursor to use for pagination + Returns: + A list of unused ontology ids + Example: + To get the first page of unused ontology ids (100 at a time) + >>> client.get_unused_ontologies() + To get the next page of unused ontology ids + >>> client.get_unused_ontologies("cleabc1my012ioqvu5anyaabc") + """ + + endpoint = self.rest_endpoint + "/ontologies/unused" + response = requests.get( + endpoint, + headers=self.headers, + json={"after": after}, + ) + + if response.status_code == requests.codes.ok: + return response.json() + else: + raise labelbox.exceptions.LabelboxError( + "Failed to get unused ontologies, message: " + + str(response.json()['message'])) + + def get_unused_feature_schemas(self, after: str = None) -> List[str]: + """ + Returns a list of unused feature schema ids + Args: + after (str): The cursor to use for pagination + Returns: + A list of unused feature schema ids + Example: + To get the first page of unused feature schema ids (100 at a time) + >>> client.get_unused_feature_schemas() + To get the next page of unused feature schema ids + >>> client.get_unused_feature_schemas("cleabc1my012ioqvu5anyaabc") + """ + + endpoint = self.rest_endpoint + "/feature-schemas/unused" + response = requests.get( + endpoint, + headers=self.headers, + json={"after": after}, + ) + + if response.status_code == requests.codes.ok: + return response.json() + else: + raise labelbox.exceptions.LabelboxError( + "Failed to get unused feature schemas, message: " + + str(response.json()['message'])) + def create_ontology(self, name, normalized, media_type=None) -> Ontology: """ Creates an ontology from normalized data diff --git a/tests/integration/test_feature_schema.py b/tests/integration/test_feature_schema.py index 2e640ce06..afca8a0d2 100644 --- a/tests/integration/test_feature_schema.py +++ b/tests/integration/test_feature_schema.py @@ -112,3 +112,18 @@ def test_updates_a_feature_schema(client): tool_to_update.asdict()) assert updated_feature_schema.normalized['name'] == "new name" + + +def test_does_not_include_used_feature_schema(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + ontology = client.create_ontology_from_feature_schemas( + name='ontology name', + feature_schema_ids=[feature_schema_id], + media_type=MediaType.Image) + unused_feature_schemas = client.get_unused_feature_schemas() + + assert feature_schema_id not in unused_feature_schemas + + client.delete_unused_ontology(ontology.uid) + client.delete_unused_feature_schema(feature_schema_id) diff --git a/tests/integration/test_ontology.py b/tests/integration/test_ontology.py index e7983b55d..9a017118b 100644 --- a/tests/integration/test_ontology.py +++ b/tests/integration/test_ontology.py @@ -108,6 +108,25 @@ def test_moves_already_added_feature_schema_in_ontology(client): client.delete_unused_ontology(ontology.uid) +def test_does_not_include_used_ontologies(client): + tool = client.upsert_feature_schema(point.asdict()) + feature_schema_id = tool.normalized['featureSchemaId'] + ontology_with_project = client.create_ontology_from_feature_schemas( + name='ontology name', + feature_schema_ids=[feature_schema_id], + media_type=MediaType.Image) + project = client.create_project(name="test project", + media_type=MediaType.Image) + project.setup_editor(ontology_with_project) + unused_ontologies = client.get_unused_ontologies() + + assert ontology_with_project.uid not in unused_ontologies + + project.delete() + client.delete_unused_ontology(ontology_with_project.uid) + client.delete_unused_feature_schema(feature_schema_id) + + def _get_attr_stringify_json(obj, attr): value = getattr(obj, attr.name) if attr.field_type.name.lower() == "json": From c8d63f60026f99341dbdb10a9124fe0912c1d246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Tue, 28 Feb 2023 19:30:59 +0100 Subject: [PATCH 7/8] add return types --- labelbox/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/labelbox/client.py b/labelbox/client.py index 993adcc7f..937849cc9 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -944,7 +944,8 @@ def delete_unused_ontology(self, ontology_id: str) -> None: "Failed to delete the ontology, message: " + str(response.json()['message'])) - def update_feature_schema_title(self, feature_schema_id: str, title: str): + def update_feature_schema_title(self, feature_schema_id: str, + title: str) -> Entity.FeatureSchema: """ Updates a title of a feature schema Args: @@ -971,7 +972,8 @@ def update_feature_schema_title(self, feature_schema_id: str, title: str): "Failed to update the feature schema, message: " + str(response.json()['message'])) - def upsert_feature_schema(self, feature_schema: Dict): + def upsert_feature_schema(self, + feature_schema: Dict) -> Entity.FeatureSchema: """ Upserts a feature schema Args: From 0903e72ace4c86550e3482fb3f0e535ba5d1d88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20G=C5=82uszek?= Date: Tue, 28 Feb 2023 20:15:59 +0100 Subject: [PATCH 8/8] add proper return type --- labelbox/client.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/labelbox/client.py b/labelbox/client.py index 937849cc9..72e8f1457 100644 --- a/labelbox/client.py +++ b/labelbox/client.py @@ -29,7 +29,7 @@ from labelbox.schema.labeling_frontend import LabelingFrontend from labelbox.schema.model import Model from labelbox.schema.model_run import ModelRun -from labelbox.schema.ontology import Ontology, Tool, Classification +from labelbox.schema.ontology import Ontology, Tool, Classification, FeatureSchema from labelbox.schema.organization import Organization from labelbox.schema.user import User from labelbox.schema.project import Project @@ -945,7 +945,7 @@ def delete_unused_ontology(self, ontology_id: str) -> None: str(response.json()['message'])) def update_feature_schema_title(self, feature_schema_id: str, - title: str) -> Entity.FeatureSchema: + title: str) -> FeatureSchema: """ Updates a title of a feature schema Args: @@ -972,8 +972,7 @@ def update_feature_schema_title(self, feature_schema_id: str, "Failed to update the feature schema, message: " + str(response.json()['message'])) - def upsert_feature_schema(self, - feature_schema: Dict) -> Entity.FeatureSchema: + def upsert_feature_schema(self, feature_schema: Dict) -> FeatureSchema: """ Upserts a feature schema Args: