diff --git a/libs/labelbox/src/labelbox/client.py b/libs/labelbox/src/labelbox/client.py index 0d8c113a3..60fb8016d 100644 --- a/libs/labelbox/src/labelbox/client.py +++ b/libs/labelbox/src/labelbox/client.py @@ -507,16 +507,16 @@ def delete_project_memberships( self, project_id: str, user_ids: list[str] ) -> dict: """Deletes project memberships for one or more users. - + Args: project_id (str): ID of the project user_ids (list[str]): List of user IDs to remove from the project - + Returns: dict: Result containing: - success (bool): True if operation succeeded - errorMessage (str or None): Error message if operation failed - + Example: >>> result = client.delete_project_memberships( >>> project_id="project123", @@ -539,12 +539,12 @@ def delete_project_memberships( errorMessage } }""" - + params = { "projectId": project_id, "userIds": user_ids, } - + result = self.execute(mutation, params) return result["deleteProjectMemberships"] diff --git a/libs/labelbox/src/labelbox/schema/api_key.py b/libs/labelbox/src/labelbox/schema/api_key.py index c5dba9148..d297e1451 100644 --- a/libs/labelbox/src/labelbox/schema/api_key.py +++ b/libs/labelbox/src/labelbox/schema/api_key.py @@ -258,7 +258,9 @@ def _get_available_api_key_roles(client: "Client") -> List[str]: if role["name"] in ["None", "Tenant Admin"]: continue if all(perm in current_permissions for perm in role["permissions"]): - available_roles.append(format_role(role["name"])) + # Preserve server-provided role names (case-sensitive) so callers can + # pass them through without normalization. + available_roles.append(role["name"]) client._cached_available_api_key_roles = available_roles return available_roles @@ -332,9 +334,25 @@ def create_api_key( raise ValueError("role must be a Role object or a valid role name") allowed_roles = ApiKey._get_available_api_key_roles(client) - # Format the input role name consistently with available roles - formatted_role_name = format_role(role_name) - if formatted_role_name not in allowed_roles: + # Determine the exact server role name to pass through. + # + # - If caller provides a string, require exact match (case-sensitive). + # - If caller provides a Role object (which may be normalized by the SDK), + # map it back to the server role name. + server_role_name: Optional[str] = None + if hasattr(role, "name"): + # Role objects in the SDK are often normalized (e.g. "TENANT_ADMIN"). + # Map normalized name back to the server-provided role display name. + normalized_to_server = {format_role(r): r for r in allowed_roles} + server_role_name = ( + role_name + if role_name in allowed_roles + else normalized_to_server.get(format_role(role_name)) + ) + else: + server_role_name = role_name if role_name in allowed_roles else None + + if server_role_name is None: raise ValueError( f"Invalid role specified. Allowed roles are: {allowed_roles}" ) @@ -371,7 +389,7 @@ def create_api_key( params = { "name": name, "userEmail": user_email, - "role": role_name, + "role": server_role_name, "validitySeconds": validity_seconds, } diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index f00a75cb2..60d6b6258 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -317,7 +317,9 @@ def get_resource_tags(self) -> List[ResourceTag]: return [ResourceTag(self.client, tag) for tag in results] - def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedCollection: + def labels( + self, datasets=None, order_by=None, created_by=None + ) -> PaginatedCollection: """Custom relationship expansion method to support limited filtering. Args: @@ -334,7 +336,7 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl Example: >>> # Get all labels >>> all_labels = project.labels() - >>> + >>> >>> # Get labels by specific user >>> user_labels = project.labels(created_by=user_id) >>> # or @@ -351,16 +353,22 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl # Build where clause where_clauses = [] - + if datasets is not None: - dataset_ids = ", ".join('"%s"' % dataset.uid for dataset in datasets) - where_clauses.append(f"dataRow: {{dataset: {{id_in: [{dataset_ids}]}}}}") - + dataset_ids = ", ".join( + '"%s"' % dataset.uid for dataset in datasets + ) + where_clauses.append( + f"dataRow: {{dataset: {{id_in: [{dataset_ids}]}}}}" + ) + if created_by is not None: # Handle both User object and user_id string - user_id = created_by.uid if hasattr(created_by, 'uid') else created_by + user_id = ( + created_by.uid if hasattr(created_by, "uid") else created_by + ) where_clauses.append(f'createdBy: {{id: "{user_id}"}}') - + if where_clauses: where = " where:{" + ", ".join(where_clauses) + "}" else: @@ -396,7 +404,7 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl def delete_labels_by_user(self, user_id: str) -> int: """Soft deletes all labels created by a specific user in this project. - + This performs a soft delete (sets deleted=true in the database). The labels will no longer appear in queries but remain in the database. Labels are deleted in chunks of 500 to avoid overwhelming the API. @@ -413,18 +421,18 @@ def delete_labels_by_user(self, user_id: str) -> int: >>> print(f"Deleted {deleted_count} labels") """ labels_to_delete = list(self.labels(created_by=user_id)) - + if not labels_to_delete: return 0 - + chunk_size = 500 total_deleted = 0 - + for i in range(0, len(labels_to_delete), chunk_size): - chunk = labels_to_delete[i:i + chunk_size] + chunk = labels_to_delete[i : i + chunk_size] Entity.Label.bulk_delete(chunk) total_deleted += len(chunk) - + return total_deleted def export( diff --git a/libs/labelbox/src/labelbox/schema/user_group.py b/libs/labelbox/src/labelbox/schema/user_group.py index e247af1c7..9b98b588a 100644 --- a/libs/labelbox/src/labelbox/schema/user_group.py +++ b/libs/labelbox/src/labelbox/schema/user_group.py @@ -9,6 +9,7 @@ from collections import defaultdict from dataclasses import dataclass from enum import Enum +import uuid from typing import Any, Dict, Iterator, List, Optional, Set from lbox.exceptions import ( @@ -415,6 +416,14 @@ def delete(self) -> bool: if not self.id: raise ValueError("Group id is required") + # The API expects a UUID-formatted identifier and may respond with an + # internal server error if the value cannot be parsed. Validate client-side + # so callers get a consistent exception. + try: + uuid.UUID(str(self.id)) + except Exception as e: + raise MalformedQueryException("Invalid user group id") from e + query = """ mutation DeleteUserGroupPyApi($id: ID!) { deleteUserGroup(where: {id: $id}) { diff --git a/libs/labelbox/tests/integration/test_project_set_model_setup_complete.py b/libs/labelbox/tests/integration/test_project_set_model_setup_complete.py index 30e179028..2ab035d95 100644 --- a/libs/labelbox/tests/integration/test_project_set_model_setup_complete.py +++ b/libs/labelbox/tests/integration/test_project_set_model_setup_complete.py @@ -36,7 +36,7 @@ def test_live_chat_evaluation_project_delete_cofig( with pytest.raises( expected_exception=LabelboxError, - match="Cannot create model config for project because model setup is complete", + match="Cannot (create model config for project because model setup is complete|perform this action because model setup is complete)", ): project_model_config.delete() diff --git a/libs/labelbox/tests/unit/schema/test_user_group.py b/libs/labelbox/tests/unit/schema/test_user_group.py index c51e60c6a..1e54332f7 100644 --- a/libs/labelbox/tests/unit/schema/test_user_group.py +++ b/libs/labelbox/tests/unit/schema/test_user_group.py @@ -341,7 +341,7 @@ def test_delete(self): "deleteUserGroup": {"success": True} } group = self.group - group.id = "group_id" + group.id = "11111111-2222-3333-4444-555555555555" result = group.delete() assert result is True @@ -350,7 +350,7 @@ def test_delete_resource_not_found_error(self): message="Not found" ) group = self.group - group.id = "group_id" + group.id = "11111111-2222-3333-4444-555555555555" with pytest.raises(ResourceNotFoundError): group.delete()