Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions libs/labelbox/src/labelbox/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -539,12 +539,12 @@ def delete_project_memberships(
errorMessage
}
}"""

params = {
"projectId": project_id,
"userIds": user_ids,
}

result = self.execute(mutation, params)
return result["deleteProjectMemberships"]

Expand Down
28 changes: 23 additions & 5 deletions libs/labelbox/src/labelbox/schema/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}"
)
Expand Down Expand Up @@ -371,7 +389,7 @@ def create_api_key(
params = {
"name": name,
"userEmail": user_email,
"role": role_name,
"role": server_role_name,
"validitySeconds": validity_seconds,
}

Expand Down
36 changes: 22 additions & 14 deletions libs/labelbox/src/labelbox/schema/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions libs/labelbox/src/labelbox/schema/user_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions libs/labelbox/tests/unit/schema/test_user_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()

Expand Down
Loading