diff --git a/docs/conf.py b/docs/conf.py index a67a44a24..51648857e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ project = 'Python SDK reference' copyright = '2024, Labelbox' author = 'Labelbox' -release = '5.0.0' +release = '5.1.0' # -- General configuration --------------------------------------------------- diff --git a/libs/labelbox/CHANGELOG.md b/libs/labelbox/CHANGELOG.md index b2d41b56d..6b23cf6bc 100644 --- a/libs/labelbox/CHANGELOG.md +++ b/libs/labelbox/CHANGELOG.md @@ -1,4 +1,9 @@ # Changelog +# Version 5.1.0 (2024-09-27) +## Fixed +* Support self-signed SSL certs([#1811](https://github.com/Labelbox/labelbox-python/pull/1811)) +* Rectangle units now correctly support percent inputs([#1848](https://github.com/Labelbox/labelbox-python/pull/1848)) + # Version 5.0.0 (2024-09-16) ## Updated * Set tasks_remaining_count to None LabelingServiceDashboard if labeling has not started ([#1817](https://github.com/Labelbox/labelbox-python/pull/1817)) diff --git a/libs/labelbox/pyproject.toml b/libs/labelbox/pyproject.toml index 11ecb478b..8a987492f 100644 --- a/libs/labelbox/pyproject.toml +++ b/libs/labelbox/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "labelbox" -version = "5.0.0" +version = "5.1.0" description = "Labelbox Python API" authors = [{ name = "Labelbox", email = "engineering@labelbox.com" }] dependencies = [ diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index 08655e833..7f7081947 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -1,6 +1,6 @@ name = "labelbox" -__version__ = "5.0.0" +__version__ = "5.1.0" from labelbox.client import Client from labelbox.schema.project import Project diff --git a/libs/labelbox/src/labelbox/client.py b/libs/labelbox/src/labelbox/client.py index 16f28148e..4498531ce 100644 --- a/libs/labelbox/src/labelbox/client.py +++ b/libs/labelbox/src/labelbox/client.py @@ -7,13 +7,19 @@ import time import urllib.parse from collections import defaultdict +from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Callable, Dict, List, Optional, Set, Union, overload -import lbox.exceptions import requests import requests.exceptions from google.api_core import retry +from lbox.exceptions import ( + InternalServerError, + LabelboxError, + ResourceNotFoundError, + TimeoutError, +) from lbox.request_client import RequestClient from labelbox import __version__ as SDK_VERSION @@ -111,7 +117,7 @@ def __init__( enable_experimental (bool): Indicates whether or not to use experimental features app_url (str) : host url for all links to the web app Raises: - lbox.exceptions.AuthenticationError: If no `api_key` + AuthenticationError: If no `api_key` is provided as an argument or via the environment variable. """ @@ -199,7 +205,7 @@ def upload_file(self, path: str) -> str: Returns: str, the URL of uploaded data. Raises: - lbox.exceptions.LabelboxError: If upload failed. + LabelboxError: If upload failed. """ content_type, _ = mimetypes.guess_type(path) filename = os.path.basename(path) @@ -208,9 +214,7 @@ def upload_file(self, path: str) -> str: content=f.read(), filename=filename, content_type=content_type ) - @retry.Retry( - predicate=retry.if_exception_type(lbox.exceptions.InternalServerError) - ) + @retry.Retry(predicate=retry.if_exception_type(InternalServerError)) def upload_data( self, content: bytes, @@ -230,7 +234,7 @@ def upload_data( str, the URL of uploaded data. Raises: - lbox.exceptions.LabelboxError: If upload failed. + LabelboxError: If upload failed. """ request_data = { @@ -271,18 +275,16 @@ def upload_data( if response.status_code == 502: error_502 = "502 Bad Gateway" - raise lbox.exceptions.InternalServerError(error_502) + raise InternalServerError(error_502) elif response.status_code == 503: - raise lbox.exceptions.InternalServerError(response.text) + raise InternalServerError(response.text) elif response.status_code == 520: - raise lbox.exceptions.InternalServerError(response.text) + raise InternalServerError(response.text) try: file_data = response.json().get("data", None) except ValueError as e: # response is not valid JSON - raise lbox.exceptions.LabelboxError( - "Failed to upload, unknown cause", e - ) + raise LabelboxError("Failed to upload, unknown cause", e) if not file_data or not file_data.get("uploadFile", None): try: @@ -292,9 +294,7 @@ def upload_data( ) except Exception: error_msg = "Unknown error" - raise lbox.exceptions.LabelboxError( - "Failed to upload, message: %s" % error_msg - ) + raise LabelboxError("Failed to upload, message: %s" % error_msg) return file_data["uploadFile"]["url"] @@ -307,7 +307,7 @@ def _get_single(self, db_object_type, uid): Returns: Object of `db_object_type`. Raises: - lbox.exceptions.ResourceNotFoundError: If there is no object + ResourceNotFoundError: If there is no object of the given type for the given ID. """ query_str, params = query.get_single(db_object_type, uid) @@ -315,7 +315,7 @@ def _get_single(self, db_object_type, uid): res = self.execute(query_str, params) res = res and res.get(utils.camel_case(db_object_type.type_name())) if res is None: - raise lbox.exceptions.ResourceNotFoundError(db_object_type, params) + raise ResourceNotFoundError(db_object_type, params) else: return db_object_type(self, res) @@ -329,7 +329,7 @@ def get_project(self, project_id) -> Project: Returns: The sought Project. Raises: - lbox.exceptions.ResourceNotFoundError: If there is no + ResourceNotFoundError: If there is no Project with the given ID. """ return self._get_single(Entity.Project, project_id) @@ -344,7 +344,7 @@ def get_dataset(self, dataset_id) -> Dataset: Returns: The sought Dataset. Raises: - lbox.exceptions.ResourceNotFoundError: If there is no + ResourceNotFoundError: If there is no Dataset with the given ID. """ return self._get_single(Entity.Dataset, dataset_id) @@ -470,7 +470,7 @@ def _create(self, db_object_type, data, extra_params={}): ) if not res: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to create %s" % db_object_type.type_name() ) res = res["create%s" % db_object_type.type_name()] @@ -528,9 +528,7 @@ def delete_model_config(self, id: str) -> bool: params = {"id": id} result = self.execute(query, params) if not result: - raise lbox.exceptions.ResourceNotFoundError( - Entity.ModelConfig, params - ) + raise ResourceNotFoundError(Entity.ModelConfig, params) return result["deleteModelConfig"]["success"] def create_dataset( @@ -589,7 +587,7 @@ def create_dataset( ) if not validation_result["validateDataset"]["valid"]: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "IAMIntegration was not successfully added to the dataset." ) except Exception as e: @@ -895,7 +893,7 @@ def get_data_row_by_global_key(self, global_key: str) -> DataRow: """ res = self.get_data_row_ids_for_global_keys([global_key]) if res["status"] != "SUCCESS": - raise lbox.exceptions.ResourceNotFoundError( + raise ResourceNotFoundError( Entity.DataRow, {global_key: global_key} ) data_row_id = res["results"][0] @@ -923,7 +921,7 @@ def get_model(self, model_id) -> Model: Returns: The sought Model. Raises: - lbox.exceptions.ResourceNotFoundError: If there is no + ResourceNotFoundError: If there is no Model with the given ID. """ return self._get_single(Entity.Model, model_id) @@ -1169,7 +1167,7 @@ def delete_unused_feature_schema(self, feature_schema_id: str) -> None: response = self.connection.delete(endpoint) if response.status_code != requests.codes.no_content: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to delete the feature schema, message: " + str(response.json()["message"]) ) @@ -1190,7 +1188,7 @@ def delete_unused_ontology(self, ontology_id: str) -> None: response = self.connection.delete(endpoint) if response.status_code != requests.codes.no_content: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to delete the ontology, message: " + str(response.json()["message"]) ) @@ -1220,7 +1218,7 @@ def update_feature_schema_title( if response.status_code == requests.codes.ok: return self.get_feature_schema(feature_schema_id) else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to update the feature schema, message: " + str(response.json()["message"]) ) @@ -1256,7 +1254,7 @@ def upsert_feature_schema(self, feature_schema: Dict) -> FeatureSchema: if response.status_code == requests.codes.ok: return self.get_feature_schema(response.json()["schemaId"]) else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to upsert the feature schema, message: " + str(response.json()["message"]) ) @@ -1284,7 +1282,7 @@ def insert_feature_schema_into_ontology( ) response = self.connection.post(endpoint, json={"position": position}) if response.status_code != requests.codes.created: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to insert the feature schema into the ontology, message: " + str(response.json()["message"]) ) @@ -1309,7 +1307,7 @@ def get_unused_ontologies(self, after: str = None) -> List[str]: if response.status_code == requests.codes.ok: return response.json() else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to get unused ontologies, message: " + str(response.json()["message"]) ) @@ -1334,7 +1332,7 @@ def get_unused_feature_schemas(self, after: str = None) -> List[str]: if response.status_code == requests.codes.ok: return response.json() else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to get unused feature schemas, message: " + str(response.json()["message"]) ) @@ -1630,12 +1628,12 @@ def _format_failed_rows( elif ( res["assignGlobalKeysToDataRowsResult"]["jobStatus"] == "FAILED" ): - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Job assign_global_keys_to_data_rows failed." ) current_time = time.time() if current_time - start_time > timeout_seconds: - raise lbox.exceptions.TimeoutError( + raise TimeoutError( "Timed out waiting for assign_global_keys_to_data_rows job to complete." ) time.sleep(sleep_time) @@ -1739,12 +1737,10 @@ def _format_failed_rows( return {"status": status, "results": results, "errors": errors} elif res["dataRowsForGlobalKeysResult"]["jobStatus"] == "FAILED": - raise lbox.exceptions.LabelboxError( - "Job dataRowsForGlobalKeys failed." - ) + raise LabelboxError("Job dataRowsForGlobalKeys failed.") current_time = time.time() if current_time - start_time > timeout_seconds: - raise lbox.exceptions.TimeoutError( + raise TimeoutError( "Timed out waiting for get_data_rows_for_global_keys job to complete." ) time.sleep(sleep_time) @@ -1843,12 +1839,10 @@ def _format_failed_rows( return {"status": status, "results": results, "errors": errors} elif res["clearGlobalKeysResult"]["jobStatus"] == "FAILED": - raise lbox.exceptions.LabelboxError( - "Job clearGlobalKeys failed." - ) + raise LabelboxError("Job clearGlobalKeys failed.") current_time = time.time() if current_time - start_time > timeout_seconds: - raise lbox.exceptions.TimeoutError( + raise TimeoutError( "Timed out waiting for clear_global_keys job to complete." ) time.sleep(sleep_time) @@ -1913,14 +1907,14 @@ def is_feature_schema_archived( if filtered_feature_schema_nodes: return bool(filtered_feature_schema_nodes[0]["archived"]) else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "The specified feature schema was not in the ontology." ) elif response.status_code == 404: - raise lbox.exceptions.ResourceNotFoundError(Ontology, ontology_id) + raise ResourceNotFoundError(Ontology, ontology_id) else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to get the feature schema archived status." ) @@ -1947,7 +1941,7 @@ def get_model_slice(self, slice_id) -> ModelSlice: """ res = self.execute(query_str, {"id": slice_id}) if res is None or res["getSavedQuery"] is None: - raise lbox.exceptions.ResourceNotFoundError(ModelSlice, slice_id) + raise ResourceNotFoundError(ModelSlice, slice_id) return Entity.ModelSlice(self, res["getSavedQuery"]) @@ -1994,7 +1988,7 @@ def delete_feature_schema_from_ontology( result.deleted = bool(response_json["deleted"]) return result else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed to remove feature schema from ontology, message: " + str(response.json()["message"]) ) @@ -2022,11 +2016,9 @@ def unarchive_feature_schema_node( response = self.connection.patch(ontology_endpoint) if response.status_code == requests.codes.ok: if not bool(response.json()["unarchived"]): - raise lbox.exceptions.LabelboxError( - "Failed unarchive the feature schema." - ) + raise LabelboxError("Failed unarchive the feature schema.") else: - raise lbox.exceptions.LabelboxError( + raise LabelboxError( "Failed unarchive the feature schema node, message: ", response.text, ) @@ -2255,7 +2247,7 @@ def get_embedding_by_name(self, name: str) -> Embedding: for e in embeddings: if e.name == name: return e - raise lbox.exceptions.ResourceNotFoundError(Embedding, dict(name=name)) + raise ResourceNotFoundError(Embedding, dict(name=name)) def upsert_label_feedback( self, label_id: str, feedback: str, scores: Dict[str, float] @@ -2378,7 +2370,7 @@ def get_task_by_id(self, task_id: str) -> Union[Task, DataUpsertTask]: result = self.execute(query, {"userId": user.uid, "taskId": task_id}) data = result.get("user", {}).get("createdTasks", []) if not data: - raise lbox.exceptions.ResourceNotFoundError( + raise ResourceNotFoundError( message=f"The task {task_id} does not exist." ) task_data = data[0] diff --git a/libs/labelbox/src/labelbox/data/annotation_types/geometry/rectangle.py b/libs/labelbox/src/labelbox/data/annotation_types/geometry/rectangle.py index 5cabf0957..af37734b0 100644 --- a/libs/labelbox/src/labelbox/data/annotation_types/geometry/rectangle.py +++ b/libs/labelbox/src/labelbox/data/annotation_types/geometry/rectangle.py @@ -94,6 +94,7 @@ class RectangleUnit(Enum): INCHES = "INCHES" PIXELS = "PIXELS" POINTS = "POINTS" + PERCENT = "PERCENT" class DocumentRectangle(Rectangle): diff --git a/libs/labelbox/tests/conftest.py b/libs/labelbox/tests/conftest.py index 484370e54..a07d52c4d 100644 --- a/libs/labelbox/tests/conftest.py +++ b/libs/labelbox/tests/conftest.py @@ -1248,6 +1248,21 @@ def teardown_ontology_feature_schemas(ontology: Ontology): class ModuleTearDownHelpers(TearDownHelpers): ... +class LabelHelpers: + def wait_for_labels(self, project, number_of_labels=1): + timeout_seconds = 10 + while True: + labels = list(project.labels()) + if len(labels) >= number_of_labels: + return labels + timeout_seconds -= 2 + if timeout_seconds <= 0: + raise TimeoutError( + f"Timed out waiting for label for project '{project.uid}' to finish processing" + ) + time.sleep(2) + + @pytest.fixture def teardown_helpers(): return TearDownHelpers() @@ -1256,3 +1271,8 @@ def teardown_helpers(): @pytest.fixture(scope="module") def module_teardown_helpers(): return TearDownHelpers() + + +@pytest.fixture +def label_helpers(): + return LabelHelpers() diff --git a/libs/labelbox/tests/data/annotation_import/test_generic_data_types.py b/libs/labelbox/tests/data/annotation_import/test_generic_data_types.py index 3fc6cddf6..921e98c9d 100644 --- a/libs/labelbox/tests/data/annotation_import/test_generic_data_types.py +++ b/libs/labelbox/tests/data/annotation_import/test_generic_data_types.py @@ -1,17 +1,18 @@ import datetime +import itertools +import uuid + +import pytest + +import labelbox as lb +from labelbox import Client, OntologyKind, Project +from labelbox.data.annotation_types import Label from labelbox.data.annotation_types.data.generic_data_row_data import ( GenericDataRowData, ) from labelbox.data.serialization.ndjson.converter import NDJsonConverter -from labelbox.data.annotation_types import Label -import pytest -import uuid - -import labelbox as lb -from labelbox.schema.media_type import MediaType from labelbox.schema.annotation_import import AnnotationImportState -from labelbox import Project, Client, OntologyKind -import itertools +from labelbox.schema.media_type import MediaType """ - integration test for importing mal labels and ground truths with each supported MediaType. @@ -39,11 +40,6 @@ def validate_iso_format(date_string: str): (MediaType.Conversational, MediaType.Conversational), (MediaType.Document, MediaType.Document), (MediaType.Dicom, MediaType.Dicom), - ( - MediaType.LLMPromptResponseCreation, - MediaType.LLMPromptResponseCreation, - ), - (MediaType.LLMPromptCreation, MediaType.LLMPromptCreation), (OntologyKind.ResponseCreation, OntologyKind.ResponseCreation), (OntologyKind.ModelEvaluation, OntologyKind.ModelEvaluation), ], @@ -57,6 +53,7 @@ def test_import_media_types( export_v2_test_helpers, helpers, media_type, + wait_for_label_processing, ): annotations_ndjson = list( itertools.chain.from_iterable(annotations_by_media_type[media_type]) @@ -73,6 +70,8 @@ def test_import_media_types( assert label_import.state == AnnotationImportState.FINISHED assert len(label_import.errors) == 0 + wait_for_label_processing(configured_project)[0] + result = export_v2_test_helpers.run_project_export_v2_task( configured_project ) @@ -110,6 +109,53 @@ def test_import_media_types( assert exported_annotations == expected_data +@pytest.mark.parametrize( + "configured_project, media_type", + [ + ( + MediaType.LLMPromptResponseCreation, + MediaType.LLMPromptResponseCreation, + ), + (MediaType.LLMPromptCreation, MediaType.LLMPromptCreation), + ], + indirect=["configured_project"], +) +def test_import_media_types_llm( + client: Client, + configured_project: Project, + annotations_by_media_type, + exports_v2_by_media_type, + export_v2_test_helpers, + helpers, + media_type, + wait_for_label_processing, +): + annotations_ndjson = list( + itertools.chain.from_iterable(annotations_by_media_type[media_type]) + ) + + label_import = lb.LabelImport.create_from_objects( + client, + configured_project.uid, + f"test-import-{media_type}", + annotations_ndjson, + ) + label_import.wait_until_done() + + assert label_import.state == AnnotationImportState.FINISHED + assert len(label_import.errors) == 0 + + all_annotations = sorted([a["uuid"] for a in annotations_ndjson]) + successful_annotations = sorted( + [ + status["uuid"] + for status in label_import.statuses + if status["status"] == "SUCCESS" + ] + ) + assert successful_annotations == all_annotations + + @pytest.mark.parametrize( "configured_project_by_global_key, media_type", [ diff --git a/libs/labelbox/tests/integration/schema/test_user_group.py b/libs/labelbox/tests/integration/schema/test_user_group.py index 60645452b..b1443b7e7 100644 --- a/libs/labelbox/tests/integration/schema/test_user_group.py +++ b/libs/labelbox/tests/integration/schema/test_user_group.py @@ -147,9 +147,6 @@ def test_cannot_update_group_id(user_group): def test_get_user_groups_with_creation_deletion(client): user_group = None try: - # Get all user groups - user_groups = list(UserGroup(client).get_user_groups()) - # manual delete for iterators group_name = data.name() user_group = UserGroup(client) @@ -157,25 +154,12 @@ def test_get_user_groups_with_creation_deletion(client): user_group.create() user_groups_post_creation = list(UserGroup(client).get_user_groups()) + assert user_group in user_groups_post_creation - # Verify that at least one user group is returned - assert len(user_groups_post_creation) > 0 - assert len(user_groups_post_creation) == len(user_groups) + 1 - - # Verify that each user group has a valid ID and name - for ug in user_groups_post_creation: - assert ug.id is not None - assert ug.name is not None - - user_group.delete() user_group = None user_groups_post_deletion = list(UserGroup(client).get_user_groups()) - - assert ( - len(user_groups_post_deletion) == len(user_groups_post_creation) - 1 - ) - + assert user_group not in user_groups_post_deletion finally: if user_group: user_group.delete() diff --git a/libs/labelbox/tests/integration/test_label.py b/libs/labelbox/tests/integration/test_label.py index 1bd8a8276..0daa4758e 100644 --- a/libs/labelbox/tests/integration/test_label.py +++ b/libs/labelbox/tests/integration/test_label.py @@ -41,11 +41,13 @@ def test_label_update(configured_project_with_label): assert label.label == "something else" -def test_label_filter_order(configured_project_with_label): +def test_label_filter_order(configured_project_with_label, label_helpers): project, _, _, label = configured_project_with_label l1 = label project.create_label() + label_helpers.wait_for_labels(project, 2) + l2 = next(project.labels()) assert set(project.labels()) == {l1, l2} diff --git a/libs/labelbox/tests/integration/test_labeling_service.py b/libs/labelbox/tests/integration/test_labeling_service.py index bba8cef78..9d5e178b6 100644 --- a/libs/labelbox/tests/integration/test_labeling_service.py +++ b/libs/labelbox/tests/integration/test_labeling_service.py @@ -1,5 +1,9 @@ import pytest -from lbox.exceptions import LabelboxError, ResourceNotFoundError +from lbox.exceptions import ( + LabelboxError, + MalformedQueryException, + ResourceNotFoundError, +) from labelbox.schema.labeling_service import LabelingServiceStatus diff --git a/libs/lbox-clients/src/lbox/request_client.py b/libs/lbox-clients/src/lbox/request_client.py index 2806573b7..dd45ce03b 100644 --- a/libs/lbox-clients/src/lbox/request_client.py +++ b/libs/lbox-clients/src/lbox/request_client.py @@ -245,7 +245,12 @@ def convert_value(value): prepped: requests.PreparedRequest = request.prepare() - response = self._connection.send(prepped, timeout=timeout) + settings = self._connection.merge_environment_settings( + prepped.url, {}, None, None, None + ) + response = self._connection.send( + prepped, timeout=timeout, **settings + ) logger.debug("Response: %s", response.text) except requests.exceptions.Timeout as e: raise exceptions.TimeoutError(str(e))