Skip to content

Commit 6cfc71f

Browse files
authored
Merge branch 'develop' into ct/prompt-issue-tool-name-correction
2 parents 9f203b9 + 8cc6076 commit 6cfc71f

14 files changed

Lines changed: 372 additions & 54 deletions

libs/labelbox/src/labelbox/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
from labelbox.schema.tool_building.step_reasoning_tool import StepReasoningTool
5656
from labelbox.schema.tool_building.prompt_issue_tool import PromptIssueTool
5757
from labelbox.schema.tool_building.relationship_tool import RelationshipTool
58-
from labelbox.schema.role import Role, ProjectRole
58+
from labelbox.schema.role import Role, ProjectRole, UserGroupRole
5959
from labelbox.schema.invite import Invite, InviteLimit
6060
from labelbox.schema.data_row_metadata import (
6161
DataRowMetadataOntology,

libs/labelbox/src/labelbox/client.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -507,16 +507,16 @@ def delete_project_memberships(
507507
self, project_id: str, user_ids: list[str]
508508
) -> dict:
509509
"""Deletes project memberships for one or more users.
510-
510+
511511
Args:
512512
project_id (str): ID of the project
513513
user_ids (list[str]): List of user IDs to remove from the project
514-
514+
515515
Returns:
516516
dict: Result containing:
517517
- success (bool): True if operation succeeded
518518
- errorMessage (str or None): Error message if operation failed
519-
519+
520520
Example:
521521
>>> result = client.delete_project_memberships(
522522
>>> project_id="project123",
@@ -539,12 +539,12 @@ def delete_project_memberships(
539539
errorMessage
540540
}
541541
}"""
542-
542+
543543
params = {
544544
"projectId": project_id,
545545
"userIds": user_ids,
546546
}
547-
547+
548548
result = self.execute(mutation, params)
549549
return result["deleteProjectMemberships"]
550550

libs/labelbox/src/labelbox/orm/model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ class Entity(metaclass=EntityMeta):
399399
CatalogSlice: Type[labelbox.CatalogSlice]
400400
ModelSlice: Type[labelbox.ModelSlice]
401401
TaskQueue: Type[labelbox.TaskQueue]
402+
UserGroupRole: Type[labelbox.UserGroupRole]
402403

403404
@classmethod
404405
def _attributes_of_type(cls, attr_type):

libs/labelbox/src/labelbox/schema/api_key.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ def _get_available_api_key_roles(client: "Client") -> List[str]:
258258
if role["name"] in ["None", "Tenant Admin"]:
259259
continue
260260
if all(perm in current_permissions for perm in role["permissions"]):
261-
available_roles.append(format_role(role["name"]))
261+
# Preserve server-provided role names (case-sensitive) so callers can
262+
# pass them through without normalization.
263+
available_roles.append(role["name"])
262264
client._cached_available_api_key_roles = available_roles
263265
return available_roles
264266

@@ -332,9 +334,25 @@ def create_api_key(
332334
raise ValueError("role must be a Role object or a valid role name")
333335

334336
allowed_roles = ApiKey._get_available_api_key_roles(client)
335-
# Format the input role name consistently with available roles
336-
formatted_role_name = format_role(role_name)
337-
if formatted_role_name not in allowed_roles:
337+
# Determine the exact server role name to pass through.
338+
#
339+
# - If caller provides a string, require exact match (case-sensitive).
340+
# - If caller provides a Role object (which may be normalized by the SDK),
341+
# map it back to the server role name.
342+
server_role_name: Optional[str] = None
343+
if hasattr(role, "name"):
344+
# Role objects in the SDK are often normalized (e.g. "TENANT_ADMIN").
345+
# Map normalized name back to the server-provided role display name.
346+
normalized_to_server = {format_role(r): r for r in allowed_roles}
347+
server_role_name = (
348+
role_name
349+
if role_name in allowed_roles
350+
else normalized_to_server.get(format_role(role_name))
351+
)
352+
else:
353+
server_role_name = role_name if role_name in allowed_roles else None
354+
355+
if server_role_name is None:
338356
raise ValueError(
339357
f"Invalid role specified. Allowed roles are: {allowed_roles}"
340358
)
@@ -371,7 +389,7 @@ def create_api_key(
371389
params = {
372390
"name": name,
373391
"userEmail": user_email,
374-
"role": role_name,
392+
"role": server_role_name,
375393
"validitySeconds": validity_seconds,
376394
}
377395

libs/labelbox/src/labelbox/schema/organization.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Dict, List, Optional, Union
1+
from typing import TYPE_CHECKING, Dict, List, Set, Optional, Union
22

33
from lbox.exceptions import LabelboxError
44

@@ -22,6 +22,7 @@
2222
ProjectRole,
2323
Role,
2424
User,
25+
UserGroupRole,
2526
)
2627

2728

@@ -65,6 +66,7 @@ def invite_user(
6566
email: str,
6667
role: "Role",
6768
project_roles: Optional[List["ProjectRole"]] = None,
69+
user_group_roles: Optional[List["UserGroupRole"]] = None,
6870
) -> "Invite":
6971
"""
7072
Invite a new member to the org. This will send the user an email invite
@@ -88,6 +90,40 @@ def invite_user(
8890
f"Project roles cannot be set for a user with organization level permissions. Found role name `{role.name}`, expected `NONE`"
8991
)
9092

93+
if user_group_roles and role.name != "NONE":
94+
raise ValueError(
95+
f"User Group roles cannot be set for a user with organization level permissions. Found role name `{role.name}`, expected `NONE`"
96+
)
97+
98+
if user_group_roles:
99+
# The backend can 500 if the same groupId appears more than once.
100+
# We dedupe exact duplicates (same groupId+roleId), but reject
101+
# conflicting assignments (same groupId with different roleId).
102+
103+
deduped_user_group_roles: Dict[str, "UserGroupRole"] = {}
104+
conflicting_user_group_ids: Set[str] = set()
105+
106+
for user_group_role in user_group_roles:
107+
user_group_id = user_group_role.user_group.id
108+
role_id = user_group_role.role.uid
109+
110+
existing = deduped_user_group_roles.get(user_group_id)
111+
if existing is None:
112+
deduped_user_group_roles[user_group_id] = user_group_role
113+
else:
114+
if existing.role.uid != role_id:
115+
conflicting_user_group_ids.add(user_group_id)
116+
117+
if conflicting_user_group_ids:
118+
conflicts_str = ", ".join(sorted(conflicting_user_group_ids))
119+
raise ValueError(
120+
"user_group_roles contains conflicting role assignments for "
121+
"the same UserGroup. Each UserGroup may only appear once. "
122+
f"Conflicting user_group.id values: {conflicts_str}"
123+
)
124+
125+
user_group_roles = list(deduped_user_group_roles.values())
126+
91127
data_param = "data"
92128
query_str = """mutation createInvitesPyApi($%s: [CreateInviteInput!]){
93129
createInvites(data: $%s){ invite { id createdAt organizationRoleName inviteeEmail inviter { %s } }}}""" % (
@@ -104,6 +140,19 @@ def invite_user(
104140
for project_role in project_roles or []
105141
]
106142

143+
user_group_ids = [
144+
user_group_role.user_group.id
145+
for user_group_role in user_group_roles or []
146+
]
147+
148+
user_group_with_role_ids = [
149+
{
150+
"groupId": user_group_role.user_group.id,
151+
"roleId": user_group_role.role.uid,
152+
}
153+
for user_group_role in user_group_roles or []
154+
]
155+
107156
res = self.client.execute(
108157
query_str,
109158
{
@@ -114,6 +163,8 @@ def invite_user(
114163
"organizationId": self.uid,
115164
"organizationRoleId": role.uid,
116165
"projects": projects,
166+
"userGroupIds": user_group_ids,
167+
"userGroupWithRoleIds": user_group_with_role_ids,
117168
}
118169
]
119170
},

libs/labelbox/src/labelbox/schema/project.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,9 @@ def get_resource_tags(self) -> List[ResourceTag]:
317317

318318
return [ResourceTag(self.client, tag) for tag in results]
319319

320-
def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedCollection:
320+
def labels(
321+
self, datasets=None, order_by=None, created_by=None
322+
) -> PaginatedCollection:
321323
"""Custom relationship expansion method to support limited filtering.
322324
323325
Args:
@@ -334,7 +336,7 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl
334336
Example:
335337
>>> # Get all labels
336338
>>> all_labels = project.labels()
337-
>>>
339+
>>>
338340
>>> # Get labels by specific user
339341
>>> user_labels = project.labels(created_by=user_id)
340342
>>> # or
@@ -351,16 +353,22 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl
351353

352354
# Build where clause
353355
where_clauses = []
354-
356+
355357
if datasets is not None:
356-
dataset_ids = ", ".join('"%s"' % dataset.uid for dataset in datasets)
357-
where_clauses.append(f"dataRow: {{dataset: {{id_in: [{dataset_ids}]}}}}")
358-
358+
dataset_ids = ", ".join(
359+
'"%s"' % dataset.uid for dataset in datasets
360+
)
361+
where_clauses.append(
362+
f"dataRow: {{dataset: {{id_in: [{dataset_ids}]}}}}"
363+
)
364+
359365
if created_by is not None:
360366
# Handle both User object and user_id string
361-
user_id = created_by.uid if hasattr(created_by, 'uid') else created_by
367+
user_id = (
368+
created_by.uid if hasattr(created_by, "uid") else created_by
369+
)
362370
where_clauses.append(f'createdBy: {{id: "{user_id}"}}')
363-
371+
364372
if where_clauses:
365373
where = " where:{" + ", ".join(where_clauses) + "}"
366374
else:
@@ -396,7 +404,7 @@ def labels(self, datasets=None, order_by=None, created_by=None) -> PaginatedColl
396404

397405
def delete_labels_by_user(self, user_id: str) -> int:
398406
"""Soft deletes all labels created by a specific user in this project.
399-
407+
400408
This performs a soft delete (sets deleted=true in the database).
401409
The labels will no longer appear in queries but remain in the database.
402410
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:
413421
>>> print(f"Deleted {deleted_count} labels")
414422
"""
415423
labels_to_delete = list(self.labels(created_by=user_id))
416-
424+
417425
if not labels_to_delete:
418426
return 0
419-
427+
420428
chunk_size = 500
421429
total_deleted = 0
422-
430+
423431
for i in range(0, len(labels_to_delete), chunk_size):
424-
chunk = labels_to_delete[i:i + chunk_size]
432+
chunk = labels_to_delete[i : i + chunk_size]
425433
Entity.Label.bulk_delete(chunk)
426434
total_deleted += len(chunk)
427-
435+
428436
return total_deleted
429437

430438
def export(

libs/labelbox/src/labelbox/schema/role.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
if TYPE_CHECKING:
88
from labelbox import Client, Project
9+
from labelbox.schema.user_group import UserGroup
910

1011
_ROLES: Optional[Dict[str, "Role"]] = None
1112

@@ -45,3 +46,9 @@ class UserRole(Role): ...
4546
class ProjectRole:
4647
project: "Project"
4748
role: Role
49+
50+
51+
@dataclass
52+
class UserGroupRole:
53+
user_group: "UserGroup"
54+
role: Role

libs/labelbox/src/labelbox/schema/user_group.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections import defaultdict
1010
from dataclasses import dataclass
1111
from enum import Enum
12+
import uuid
1213
from typing import Any, Dict, Iterator, List, Optional, Set
1314

1415
from lbox.exceptions import (
@@ -415,6 +416,14 @@ def delete(self) -> bool:
415416
if not self.id:
416417
raise ValueError("Group id is required")
417418

419+
# The API expects a UUID-formatted identifier and may respond with an
420+
# internal server error if the value cannot be parsed. Validate client-side
421+
# so callers get a consistent exception.
422+
try:
423+
uuid.UUID(str(self.id))
424+
except Exception as e:
425+
raise MalformedQueryException("Invalid user group id") from e
426+
418427
query = """
419428
mutation DeleteUserGroupPyApi($id: ID!) {
420429
deleteUserGroup(where: {id: $id}) {

libs/labelbox/src/labelbox/schema/workflow/workflow_utils.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -117,31 +117,6 @@ def validate_node_connections(
117117
"node_type": node_type,
118118
}
119119
)
120-
elif len(predecessors) > 1:
121-
# Check if all predecessors are initial nodes
122-
node_map = {n.id: n for n in nodes}
123-
predecessor_nodes = [
124-
node_map.get(pred_id) for pred_id in predecessors
125-
]
126-
all_initial = all(
127-
pred_node
128-
and pred_node.definition_id in initial_node_types
129-
for pred_node in predecessor_nodes
130-
if pred_node is not None
131-
)
132-
133-
if not all_initial:
134-
preds_info = ", ".join(
135-
[p[:8] + "..." for p in predecessors]
136-
)
137-
errors.append(
138-
{
139-
"reason": f"has multiple incoming connections ({len(predecessors)}) but not all are from initial nodes",
140-
"node_id": node.id,
141-
"node_type": node_type,
142-
"details": f"Connected from: {preds_info}",
143-
}
144-
)
145120

146121
# Check outgoing connections (except terminal nodes)
147122
if node.definition_id not in terminal_node_types:

libs/labelbox/tests/integration/test_project_set_model_setup_complete.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_live_chat_evaluation_project_delete_cofig(
3636

3737
with pytest.raises(
3838
expected_exception=LabelboxError,
39-
match="Cannot create model config for project because model setup is complete",
39+
match="Cannot (create model config for project because model setup is complete|perform this action because model setup is complete)",
4040
):
4141
project_model_config.delete()
4242

0 commit comments

Comments
 (0)