From b55a875165e160a2f2a2e9d417676ffa27c28c83 Mon Sep 17 00:00:00 2001 From: Pablo San Pietro Date: Fri, 16 Oct 2026 14:52:00 -0300 Subject: [PATCH 1/3] [PLT-4659] [PLT-4608] [PLT-4609] Add bulk invite results to the SDK - The SDK calls inviteMembers in groups of 100 addresses. - Each address gets one result, in the original order. Jira: https://labelbox.atlassian.net/browse/PLT-4659 Jira: https://labelbox.atlassian.net/browse/PLT-4608 Jira: https://labelbox.atlassian.net/browse/PLT-4609 Co-authored-by: Cursor --- docs/labelbox/index.rst | 1 + docs/labelbox/invite.rst | 6 + libs/labelbox/src/labelbox/__init__.py | 14 +- libs/labelbox/src/labelbox/schema/invite.py | 32 +++- .../src/labelbox/schema/organization.py | 133 +++++++++++++- .../schema/test_organization_invite_users.py | 163 ++++++++++++++++++ 6 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 docs/labelbox/invite.rst create mode 100644 libs/labelbox/tests/unit/schema/test_organization_invite_users.py diff --git a/docs/labelbox/index.rst b/docs/labelbox/index.rst index fb7c6e1b5..b37787a9d 100644 --- a/docs/labelbox/index.rst +++ b/docs/labelbox/index.rst @@ -24,6 +24,7 @@ Labelbox Python SDK Documentation foundry-model identifiable identifiables + invite issue issue-category issue-position diff --git a/docs/labelbox/invite.rst b/docs/labelbox/invite.rst new file mode 100644 index 000000000..5ab40444c --- /dev/null +++ b/docs/labelbox/invite.rst @@ -0,0 +1,6 @@ +Invite +=============================================================================================== + +.. automodule:: labelbox.schema.invite + :members: + :show-inheritance: diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index fbd815b35..52107fa9f 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -43,7 +43,12 @@ from labelbox.schema.iam_integration import IAMIntegration from labelbox.schema.identifiable import GlobalKey, UniqueId from labelbox.schema.identifiables import DataRowIds, GlobalKeys, UniqueIds -from labelbox.schema.invite import Invite, InviteLimit +from labelbox.schema.invite import ( + Invite, + InviteLimit, + InviteMemberStatus, + InviteResult, +) from labelbox.schema.issue import Comment, Issue, IssueStatus from labelbox.schema.issue_category import IssueCategory from labelbox.schema.issue_position import ( @@ -78,7 +83,12 @@ from labelbox.schema.tool_building.prompt_issue_tool import PromptIssueTool from labelbox.schema.tool_building.relationship_tool import RelationshipTool from labelbox.schema.role import Role, ProjectRole, UserGroupRole -from labelbox.schema.invite import Invite, InviteLimit +from labelbox.schema.invite import ( + Invite, + InviteLimit, + InviteMemberStatus, + InviteResult, +) from labelbox.schema.data_row_metadata import ( DataRowMetadataOntology, DataRowMetadataField, diff --git a/libs/labelbox/src/labelbox/schema/invite.py b/libs/labelbox/src/labelbox/schema/invite.py index 6760d90ad..6b6c538b2 100644 --- a/libs/labelbox/src/labelbox/schema/invite.py +++ b/libs/labelbox/src/labelbox/schema/invite.py @@ -1,5 +1,6 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Union from dataclasses import dataclass +from enum import Enum from labelbox.orm.db_object import DbObject from labelbox.orm.model import Field @@ -24,6 +25,35 @@ class InviteLimit: limit: int +class InviteMemberStatus(str, Enum): + """Outcome of one address in `Organization.invite_users`.""" + + INVITED = "INVITED" + ADDED = "ADDED" + ALREADY_MEMBER = "ALREADY_MEMBER" + ALREADY_INVITED = "ALREADY_INVITED" + INVALID_EMAIL = "INVALID_EMAIL" + DUPLICATE = "DUPLICATE" + SEAT_LIMIT = "SEAT_LIMIT" + NOT_ALLOWED = "NOT_ALLOWED" + FAILED = "FAILED" + + +@dataclass +class InviteResult: + """ + email (str): The address as the server checked it: trimmed and lowercased when valid, trimmed when invalid + status (InviteMemberStatus | str): The outcome; an unknown future value stays a string + reason (Optional[str]): Why the address is not allowed or already a member, for example `GROUP_ACCESS` + invite_id (Optional[str]): The invite id when an invite was created + """ + + email: str + status: Union[InviteMemberStatus, str] + reason: Optional[str] + invite_id: Optional[str] + + class Invite(DbObject): """ An object representing a user invite diff --git a/libs/labelbox/src/labelbox/schema/organization.py b/libs/labelbox/src/labelbox/schema/organization.py index 4b84b5bba..a71e3bef6 100644 --- a/libs/labelbox/src/labelbox/schema/organization.py +++ b/libs/labelbox/src/labelbox/schema/organization.py @@ -5,7 +5,11 @@ from labelbox import utils from labelbox.orm.db_object import DbObject, Entity, query from labelbox.orm.model import Field, Relationship -from labelbox.schema.invite import InviteLimit +from labelbox.schema.invite import ( + InviteLimit, + InviteMemberStatus, + InviteResult, +) from labelbox.schema.resource_tag import ResourceTag from labelbox.pagination import PaginatedCollection from labelbox.schema.iam_integration import ( @@ -19,11 +23,16 @@ from labelbox import ( Invite, InviteLimit, + Project, ProjectRole, Role, User, UserGroupRole, ) + from labelbox.schema.user_group import UserGroup + +# The server accepts at most this many addresses per inviteMembers request. +INVITE_USERS_BATCH_SIZE = 100 class Organization(DbObject): @@ -174,6 +183,121 @@ def invite_user( raise LabelboxError(f"Unable to send invite for email {email}") return Entity.Invite(self.client, invite_response) + def invite_users( + self, + emails: List[str], + role: "Role", + projects: Optional[List[Union["Project", str]]] = None, + user_groups: Optional[List[Union["UserGroup", str]]] = None, + ) -> List[InviteResult]: + """ + Invite many people at once and get one result per address. + + Without `projects` and `user_groups` this is a workspace-wide invitation and + `role` is the organization role. With projects or user groups, `role` is the + role given on every project and group, and the people join the workspace + without an organization role. + + One bad address does not stop the others. Each address follows the same rules + as a single invitation: permissions, seat limits and invitation emails. + Sending the same list again is safe: addresses invited before come back as + `ALREADY_INVITED` or `ALREADY_MEMBER` and get no second email. + + Args: + emails (List[str]): Addresses to invite. They are sent in requests of 100. + role (Role): Organization role (workspace-wide) or project and group role. + projects (Optional[List[Union[Project, str]]]): Projects or project ids. + user_groups (Optional[List[Union[UserGroup, str]]]): User groups or group ids. + + Returns: + List[InviteResult]: One result per entry of `emails`, in order. See + `InviteMemberStatus` for the possible statuses. + + Raises: + ValueError: If `emails` is empty, or a workspace-wide invitation uses the `NONE` role. + LabelboxError: If a request is rejected as a whole, for example for a + missing permission or a role you cannot assign. Requests before it + stay sent; send the same list again to continue. + """ + if isinstance(emails, str): + raise ValueError("emails must be a list of addresses, not a string") + if not emails: + raise ValueError("emails must contain at least one address") + + project_ids = [ + project if isinstance(project, str) else project.uid + for project in projects or [] + ] + group_ids = [ + group if isinstance(group, str) else group.id + for group in user_groups or [] + ] + workspace_wide = not project_ids and not group_ids + if workspace_wide and role.name == "NONE": + raise ValueError( + "A workspace-wide invitation needs an organization role. Pass projects or user_groups to give a project role." + ) + + query_str = """mutation InviteMembersPyApi($data: InviteMembersInput!) { + inviteMembers(data: $data) { + results { email status reason inviteId } + } + }""" + + # The server finds repeats only inside one request, so repeats across + # requests are answered here, as the server would answer them. + first_index: Dict[str, int] = {} + unique: List[str] = [] + for email in emails: + key = email.strip().lower() + if key not in first_index: + first_index[key] = len(unique) + unique.append(email) + + sent: List[InviteResult] = [] + for start in range(0, len(unique), INVITE_USERS_BATCH_SIZE): + res = self.client.execute( + query_str, + { + "data": { + "emails": unique[ + start : start + INVITE_USERS_BATCH_SIZE + ], + "workspaceWide": workspace_wide, + "roleId": role.uid, + "projectIds": project_ids, + "groupIds": group_ids, + } + }, + ) + for result in res["inviteMembers"]["results"]: + sent.append( + InviteResult( + email=result["email"], + status=_invite_status(result["status"]), + reason=result.get("reason"), + invite_id=result.get("inviteId"), + ) + ) + + results: List[InviteResult] = [] + seen: Set[str] = set() + for email in emails: + key = email.strip().lower() + if key in seen: + results.append( + InviteResult( + email=sent[first_index[key]].email, + status=InviteMemberStatus.DUPLICATE, + reason=None, + invite_id=None, + ) + ) + else: + seen.add(key) + results.append(sent[first_index[key]]) + return results + def invite_limit(self) -> InviteLimit: """Retrieve invite limits for the org This already accounts for users currently in the org @@ -346,3 +470,10 @@ def get_project_invites(self, project_id: str) -> PaginatedCollection: PaginatedCollection: A collection of Invite objects for the specified project. """ return Entity.Invite.get_project_invites(self.client, project_id) + + +def _invite_status(value: str) -> Union[InviteMemberStatus, str]: + try: + return InviteMemberStatus(value) + except ValueError: + return value diff --git a/libs/labelbox/tests/unit/schema/test_organization_invite_users.py b/libs/labelbox/tests/unit/schema/test_organization_invite_users.py new file mode 100644 index 000000000..b2fc08f75 --- /dev/null +++ b/libs/labelbox/tests/unit/schema/test_organization_invite_users.py @@ -0,0 +1,163 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from labelbox.schema.invite import InviteMemberStatus, InviteResult +from labelbox.schema.organization import Organization + + +def _organization(client): + return Organization( + client, + { + "id": "org-id", + "name": "Test Org", + "createdAt": "2020-01-01T00:00:00.000Z", + "updatedAt": "2020-01-01T00:00:00.000Z", + }, + ) + + +def _echo_results(query, params): + return { + "inviteMembers": { + "results": [ + { + "email": email.strip().lower(), + "status": "INVITED", + "reason": None, + "inviteId": f"invite-{index}", + } + for index, email in enumerate(params["data"]["emails"]) + ] + } + } + + +def test_invite_users_workspace_wide_payload(): + client = MagicMock() + client.execute.side_effect = _echo_results + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + + results = _organization(client).invite_users( + ["Ana@X.com", "bob@x.com"], labeler + ) + + query, params = client.execute.call_args.args + assert "inviteMembers" in query + assert params == { + "data": { + "emails": ["Ana@X.com", "bob@x.com"], + "workspaceWide": True, + "roleId": "labeler-id", + "projectIds": [], + "groupIds": [], + } + } + assert results == [ + InviteResult("ana@x.com", InviteMemberStatus.INVITED, None, "invite-0"), + InviteResult("bob@x.com", InviteMemberStatus.INVITED, None, "invite-1"), + ] + + +def test_invite_users_project_and_group_payload_accepts_objects_and_ids(): + client = MagicMock() + client.execute.side_effect = _echo_results + reviewer = SimpleNamespace(uid="reviewer-id", name="REVIEWER") + + _organization(client).invite_users( + ["ana@x.com"], + reviewer, + projects=[SimpleNamespace(uid="project-1"), "project-2"], + user_groups=[SimpleNamespace(id="group-1"), "group-2"], + ) + + data = client.execute.call_args.args[1]["data"] + assert data["workspaceWide"] is False + assert data["roleId"] == "reviewer-id" + assert data["projectIds"] == ["project-1", "project-2"] + assert data["groupIds"] == ["group-1", "group-2"] + + +def test_invite_users_sends_requests_of_100_and_keeps_order(): + client = MagicMock() + client.execute.side_effect = _echo_results + emails = [f"user{index}@x.com" for index in range(230)] + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + + results = _organization(client).invite_users(emails, labeler) + + sizes = [ + len(call.args[1]["data"]["emails"]) + for call in client.execute.call_args_list + ] + assert sizes == [100, 100, 30] + assert [result.email for result in results] == emails + + +def test_invite_users_parses_reasons_and_unknown_statuses(): + client = MagicMock() + client.execute.return_value = { + "inviteMembers": { + "results": [ + { + "email": "grp@x.com", + "status": "NOT_ALLOWED", + "reason": "GROUP_ACCESS", + "inviteId": None, + }, + { + "email": "new@x.com", + "status": "SOMETHING_NEW", + "reason": None, + "inviteId": None, + }, + ] + } + } + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + + results = _organization(client).invite_users( + ["grp@x.com", "new@x.com"], labeler, projects=["project-1"] + ) + + assert results[0].status is InviteMemberStatus.NOT_ALLOWED + assert results[0].reason == "GROUP_ACCESS" + assert results[1].status == "SOMETHING_NEW" + + +def test_invite_users_rejects_an_empty_list(): + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + with pytest.raises(ValueError): + _organization(MagicMock()).invite_users([], labeler) + + +def test_invite_users_rejects_workspace_wide_none_role(): + client = MagicMock() + none_role = SimpleNamespace(uid="none-id", name="NONE") + + with pytest.raises(ValueError): + _organization(client).invite_users(["ana@x.com"], none_role) + client.execute.assert_not_called() + + +def test_invite_users_rejects_a_single_string(): + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + with pytest.raises(ValueError): + _organization(MagicMock()).invite_users("ana@x.com", labeler) + + +def test_invite_users_answers_repeats_across_requests_as_duplicates(): + client = MagicMock() + client.execute.side_effect = _echo_results + emails = [f"user{index}@x.com" for index in range(100)] + [" USER0@x.com"] + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + + results = _organization(client).invite_users(emails, labeler) + + assert client.execute.call_count == 1 + assert len(results) == 101 + assert results[-1] == InviteResult( + "user0@x.com", InviteMemberStatus.DUPLICATE, None, None + ) From 2e2b3ece9b9c73f37b682221322776db9c2cc180 Mon Sep 17 00:00:00 2001 From: Pablo San Pietro Date: Fri, 16 Oct 2026 15:05:31 -0300 Subject: [PATCH 2/3] [PLT-4659] [PLT-4608] [PLT-4609] Treat a long address as invalid - An address longer than 320 characters gets an invalid result. - The other addresses in the request are still sent. Jira: https://labelbox.atlassian.net/browse/PLT-4659 Jira: https://labelbox.atlassian.net/browse/PLT-4608 Jira: https://labelbox.atlassian.net/browse/PLT-4609 Co-authored-by: Cursor --- .../src/labelbox/schema/organization.py | 23 +++++++++++++++---- .../schema/test_organization_invite_users.py | 19 +++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/organization.py b/libs/labelbox/src/labelbox/schema/organization.py index a71e3bef6..131f65b8d 100644 --- a/libs/labelbox/src/labelbox/schema/organization.py +++ b/libs/labelbox/src/labelbox/schema/organization.py @@ -33,6 +33,8 @@ # The server accepts at most this many addresses per inviteMembers request. INVITE_USERS_BATCH_SIZE = 100 +# A longer raw address makes the server reject the whole request. +INVITE_EMAIL_MAX_LENGTH = 320 class Organization(DbObject): @@ -248,11 +250,21 @@ def invite_users( # requests are answered here, as the server would answer them. first_index: Dict[str, int] = {} unique: List[str] = [] + blocked: Dict[str, InviteResult] = {} for email in emails: key = email.strip().lower() - if key not in first_index: - first_index[key] = len(unique) - unique.append(email) + if key in first_index or key in blocked: + continue + if len(email) > INVITE_EMAIL_MAX_LENGTH: + blocked[key] = InviteResult( + email=email.strip(), + status=InviteMemberStatus.INVALID_EMAIL, + reason=None, + invite_id=None, + ) + continue + first_index[key] = len(unique) + unique.append(email) sent: List[InviteResult] = [] for start in range(0, len(unique), INVITE_USERS_BATCH_SIZE): @@ -284,10 +296,11 @@ def invite_users( seen: Set[str] = set() for email in emails: key = email.strip().lower() + prior = blocked[key] if key in blocked else sent[first_index[key]] if key in seen: results.append( InviteResult( - email=sent[first_index[key]].email, + email=prior.email, status=InviteMemberStatus.DUPLICATE, reason=None, invite_id=None, @@ -295,7 +308,7 @@ def invite_users( ) else: seen.add(key) - results.append(sent[first_index[key]]) + results.append(prior) return results def invite_limit(self) -> InviteLimit: diff --git a/libs/labelbox/tests/unit/schema/test_organization_invite_users.py b/libs/labelbox/tests/unit/schema/test_organization_invite_users.py index b2fc08f75..785c230d4 100644 --- a/libs/labelbox/tests/unit/schema/test_organization_invite_users.py +++ b/libs/labelbox/tests/unit/schema/test_organization_invite_users.py @@ -148,6 +148,25 @@ def test_invite_users_rejects_a_single_string(): _organization(MagicMock()).invite_users("ana@x.com", labeler) +def test_invite_users_keeps_an_over_long_address_out_of_the_batch(): + client = MagicMock() + client.execute.side_effect = _echo_results + long_address = "a" * 321 + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + + results = _organization(client).invite_users( + ["ana@x.com", long_address, " " + long_address], labeler + ) + + sent = client.execute.call_args.args[1]["data"]["emails"] + assert sent == ["ana@x.com"] + assert results[0].status is InviteMemberStatus.INVITED + assert results[1] == InviteResult( + long_address, InviteMemberStatus.INVALID_EMAIL, None, None + ) + assert results[2].status is InviteMemberStatus.DUPLICATE + + def test_invite_users_answers_repeats_across_requests_as_duplicates(): client = MagicMock() client.execute.side_effect = _echo_results From 682cb5c0900b3d3f4e655db391a60a33a5cec779 Mon Sep 17 00:00:00 2001 From: Pablo San Pietro Date: Fri, 16 Oct 2026 15:11:32 -0300 Subject: [PATCH 3/3] [PLT-4659] [PLT-4608] [PLT-4609] Send a trimmed long address - The method trims padding before it checks the 320-character limit. - A valid address is still sent when an earlier padded copy is too long. Jira: https://labelbox.atlassian.net/browse/PLT-4659 Jira: https://labelbox.atlassian.net/browse/PLT-4608 Jira: https://labelbox.atlassian.net/browse/PLT-4609 Co-authored-by: Cursor --- .../src/labelbox/schema/organization.py | 11 +++++++---- .../schema/test_organization_invite_users.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/organization.py b/libs/labelbox/src/labelbox/schema/organization.py index 131f65b8d..ea4f9f1c3 100644 --- a/libs/labelbox/src/labelbox/schema/organization.py +++ b/libs/labelbox/src/labelbox/schema/organization.py @@ -252,19 +252,22 @@ def invite_users( unique: List[str] = [] blocked: Dict[str, InviteResult] = {} for email in emails: - key = email.strip().lower() + trimmed = email.strip() + key = trimmed.lower() if key in first_index or key in blocked: continue - if len(email) > INVITE_EMAIL_MAX_LENGTH: + if len(trimmed) > INVITE_EMAIL_MAX_LENGTH: blocked[key] = InviteResult( - email=email.strip(), + email=trimmed, status=InviteMemberStatus.INVALID_EMAIL, reason=None, invite_id=None, ) continue first_index[key] = len(unique) - unique.append(email) + unique.append( + trimmed if len(email) > INVITE_EMAIL_MAX_LENGTH else email + ) sent: List[InviteResult] = [] for start in range(0, len(unique), INVITE_USERS_BATCH_SIZE): diff --git a/libs/labelbox/tests/unit/schema/test_organization_invite_users.py b/libs/labelbox/tests/unit/schema/test_organization_invite_users.py index 785c230d4..55bbfd794 100644 --- a/libs/labelbox/tests/unit/schema/test_organization_invite_users.py +++ b/libs/labelbox/tests/unit/schema/test_organization_invite_users.py @@ -167,6 +167,24 @@ def test_invite_users_keeps_an_over_long_address_out_of_the_batch(): assert results[2].status is InviteMemberStatus.DUPLICATE +def test_invite_users_sends_a_valid_address_hidden_by_padding(): + client = MagicMock() + client.execute.side_effect = _echo_results + padded = "ana@x.com" + (" " * 320) + labeler = SimpleNamespace(uid="labeler-id", name="LABELER") + + results = _organization(client).invite_users( + [padded, "bob@x.com", "ana@x.com"], labeler + ) + + sent = client.execute.call_args.args[1]["data"]["emails"] + assert sent == ["ana@x.com", "bob@x.com"] + assert results[0].status is InviteMemberStatus.INVITED + assert results[0].email == "ana@x.com" + assert results[1].email == "bob@x.com" + assert results[2].status is InviteMemberStatus.DUPLICATE + + def test_invite_users_answers_repeats_across_requests_as_duplicates(): client = MagicMock() client.execute.side_effect = _echo_results