From 31276e63e3a6bc5ac7d92d0bb0d064bf9794ebde Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Fri, 24 May 2024 22:27:55 +0100 Subject: [PATCH 1/6] PLT-1016 - Add methods for External Workforce management --- docs/labelbox/external-workforce.rst | 6 + .../src/labelbox/schema/external_workforce.py | 12 ++ libs/labelbox/src/labelbox/schema/project.py | 109 ++++++++++++++++++ .../integration/test_external_workforce.py | 27 +++++ 4 files changed, 154 insertions(+) create mode 100644 docs/labelbox/external-workforce.rst create mode 100644 libs/labelbox/src/labelbox/schema/external_workforce.py create mode 100644 libs/labelbox/tests/integration/test_external_workforce.py diff --git a/docs/labelbox/external-workforce.rst b/docs/labelbox/external-workforce.rst new file mode 100644 index 000000000..2c607d83c --- /dev/null +++ b/docs/labelbox/external-workforce.rst @@ -0,0 +1,6 @@ +ExternalWorkforce +=============================================================================================== + +.. automodule:: labelbox.schema.external_workforce + :members: + :show-inheritance: \ No newline at end of file diff --git a/libs/labelbox/src/labelbox/schema/external_workforce.py b/libs/labelbox/src/labelbox/schema/external_workforce.py new file mode 100644 index 000000000..6bfa35499 --- /dev/null +++ b/libs/labelbox/src/labelbox/schema/external_workforce.py @@ -0,0 +1,12 @@ +from labelbox.pydantic_compat import BaseModel + +class ExternalWorkforce(BaseModel): + """ + Represents an external workforce used in the Labelbox system. + + Attributes: + id (str): The unique identifier of the external workforce. + name (str): The name of the external workforce. + """ + id: str + name: str diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index 9ffed2816..75430ea85 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -42,6 +42,7 @@ from labelbox.schema.task_queue import TaskQueue from labelbox.schema.ontology_kind import (EditorTaskType, OntologyKind) from labelbox.schema.project_overview import ProjectOverview +from labelbox.schema.external_workforce import ExternalWorkforce if TYPE_CHECKING: from labelbox import BulkImportRequest @@ -1826,6 +1827,114 @@ def clone(self) -> "Project": """ result = self.client.execute(mutation, {"projectId": self.uid}) return self.client.get_project(result["cloneProject"]["id"]) + + def add_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) -> List[ExternalWorkforce]: + + """ + Add an external workforce (organization) to a project. + + Args: + workforce_id (Union[str, ExternalWorkforce]): Organization id of the external workforce. + + Returns: + List[ExternalWorkforce]: The remaining external workforces or an empty list if no external workforces are left. + + Raises: + LabelboxError: If the external workforce with the given ID cannot be found. + + Note: + This method adds an external workforce (organization) to the current project. + The `workforce_id` parameter can be either a string representing the organization ID or an instance of the `ExternalWorkforce` class. + """ + workforce_id = workforce_id.uid if isinstance(workforce_id, ExternalWorkforce) else workforce_id + + mutation = """ + mutation ShareProjectWithExternalOrganizationPyApi($projectId: ID!, $organizationId: ID!) { + shareProjectWithExternalOrganization( + data: { projectId: $projectId, organizationId: $organizationId } + ) { + id + sharedWithOrganizations { + id + name + } + } + } + """ + + result = self.client.execute(mutation, {"projectId": self.uid, "organizationId": workforce_id}) + + if not result: + raise LabelboxError(f"Can't find External Workforce {workforce_id}") + + return [ExternalWorkforce(**workforce_dic) + for workforce_dic + in result["shareProjectWithExternalOrganization"]["sharedWithOrganizations"]] + + + def remove_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) -> List[ExternalWorkforce]: + """Remove an external workforce (organization) from a project. + + Args: + workforce_id (Union[str, ExternalWorkforce]): Organization id of the external workforce + or an instance of the ExternalWorkforce class. + + Returns: + List[ExternalWorkforce]: The remaining external workforces or an empty list if no external workforces are left. + + Raises: + LabelboxError: If the external workforce cannot be found. + """ + + mutation = """ + mutation UnshareProjectWithExternalOrganizationPyApi($projectId: ID!, $organizationId: ID!) { + unshareProjectWithExternalOrganization( + data: { projectId: $projectId, organizationId: $organizationId } + ) { + id + sharedWithOrganizations { + id + name + } + } + } + """ + + result = self.client.execute(mutation, {"projectId": self.uid, "organizationId": workforce_id}) + + if not result: + raise LabelboxError(f"Can't find External Workforce {workforce_id}") + + return [ExternalWorkforce(**workforce_dic) + for workforce_dic + in result["unshareProjectWithExternalOrganization"]["sharedWithOrganizations"]] + + + def get_external_workforces(self) -> List[ExternalWorkforce]: + """List the external workforces (organizations) attached to a project + + Args: + project_id: Id of the project to check Project to check + + Returns: + list of dictionaries with id and name for each external workforce (aka organization) + or empty list if no external workforces were found + """ + + query = """ + query GetProjectExternalOganizationsPyApi($projectId: ID!) { + project(where: { id: $projectId }) { + id + sharedWithOrganizations { + id + name + } + } + } + """ + + result = self.client.execute(query, {"projectId": self.uid})["project"]["sharedWithOrganizations"] + return [ExternalWorkforce(**workforce_dic) for workforce_dic in result] class ProjectMember(DbObject): diff --git a/libs/labelbox/tests/integration/test_external_workforce.py b/libs/labelbox/tests/integration/test_external_workforce.py new file mode 100644 index 000000000..2de0d4f6a --- /dev/null +++ b/libs/labelbox/tests/integration/test_external_workforce.py @@ -0,0 +1,27 @@ +from labelbox import Project, ExternalWorkforce +import os + + +def test_add_external_workforce(project: Project): + # org id from https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests + workforce_id = "clum46jsb00jp07z338dh1gvb" if os.environ['LABELBOX_TEST_ENVIRON'] == "staging" else "ckcz6bubudyfi0855o1dt1g9s" + + external_workforces = project.add_external_workforce(workforce_id) + assert len(external_workforces) == 1 + assert isinstance(external_workforces[0], ExternalWorkforce) + + +def test_get_external_workforces(project: Project): + external_workforces = project.external_workforces() + assert len(external_workforces) == 1 + assert isinstance(external_workforces[0], ExternalWorkforce) + + +def test_remove_external_workforce(project: Project): + # org id from https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests + workforce_id = "clum46jsb00jp07z338dh1gvb" if os.environ['LABELBOX_TEST_ENVIRON'] == "staging" else "ckcz6bubudyfi0855o1dt1g9s" + + external_workforces = project.remove_external_workforce(workforce_id) + assert len(external_workforces) == 0 + + From be9461ba3170e4e2b7a2348ddde09faaa534312c Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Fri, 24 May 2024 22:34:43 +0100 Subject: [PATCH 2/6] Add support of ExternalWorkforce as an input for remove_External_workforce --- libs/labelbox/src/labelbox/schema/project.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index 75430ea85..dd1799568 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -1885,6 +1885,7 @@ def remove_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) Raises: LabelboxError: If the external workforce cannot be found. """ + workforce_id = workforce_id.uid if isinstance(workforce_id, ExternalWorkforce) else workforce_id mutation = """ mutation UnshareProjectWithExternalOrganizationPyApi($projectId: ID!, $organizationId: ID!) { From de71d69d81997ae7bed21693c758a3082a78d6c4 Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Fri, 24 May 2024 22:51:08 +0100 Subject: [PATCH 3/6] Expose ExternalWorkforce --- libs/labelbox/src/labelbox/__init__.py | 1 + libs/labelbox/src/labelbox/schema/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index 85620f851..f9ee25929 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -39,3 +39,4 @@ from labelbox.schema.identifiables import UniqueIds, GlobalKeys, DataRowIds from labelbox.schema.identifiable import UniqueId, GlobalKey from labelbox.schema.ontology_kind import OntologyKind +from labelbox.schema.external_workforce import ExternalWorkforce diff --git a/libs/labelbox/src/labelbox/schema/__init__.py b/libs/labelbox/src/labelbox/schema/__init__.py index 10a3e08ee..867c37228 100644 --- a/libs/labelbox/src/labelbox/schema/__init__.py +++ b/libs/labelbox/src/labelbox/schema/__init__.py @@ -25,3 +25,4 @@ import labelbox.schema.identifiable import labelbox.schema.catalog import labelbox.schema.ontology_kind +import labelbox.schema.external_workforce From e08cb0baaa4e3963ab4c5ac6a29257df541079e6 Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Fri, 24 May 2024 23:09:31 +0100 Subject: [PATCH 4/6] Fix typo --- libs/labelbox/tests/integration/test_external_workforce.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/labelbox/tests/integration/test_external_workforce.py b/libs/labelbox/tests/integration/test_external_workforce.py index 2de0d4f6a..329938364 100644 --- a/libs/labelbox/tests/integration/test_external_workforce.py +++ b/libs/labelbox/tests/integration/test_external_workforce.py @@ -12,7 +12,7 @@ def test_add_external_workforce(project: Project): def test_get_external_workforces(project: Project): - external_workforces = project.external_workforces() + external_workforces = project.get_external_workforces() assert len(external_workforces) == 1 assert isinstance(external_workforces[0], ExternalWorkforce) From be3afc3db2994a105d3a6b4af0af11cfa2d6b928 Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Fri, 24 May 2024 23:56:16 +0100 Subject: [PATCH 5/6] Update test to obtain an org id --- .../integration/test_external_workforce.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/libs/labelbox/tests/integration/test_external_workforce.py b/libs/labelbox/tests/integration/test_external_workforce.py index 329938364..f408b76bb 100644 --- a/libs/labelbox/tests/integration/test_external_workforce.py +++ b/libs/labelbox/tests/integration/test_external_workforce.py @@ -1,10 +1,20 @@ from labelbox import Project, ExternalWorkforce import os +import sys +def _get_workforce_id_for_test() -> str: + # Basic function to provide an organization id for the test + # org id from https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests + current_version = sys.version_info[:2] + + if os.environ['LABELBOX_TEST_ENVIRON'] == "staging": + return "cltp16p2v04dr07ywfgqxf23u" if current_version == (3, 8) else "clum46jsb00jp07z338dh1gvb" + else: + return "cltp1fral01dh07009zsng3zs" if current_version == (3, 8) else "ckcz6bubudyfi0855o1dt1g9s" + def test_add_external_workforce(project: Project): - # org id from https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests - workforce_id = "clum46jsb00jp07z338dh1gvb" if os.environ['LABELBOX_TEST_ENVIRON'] == "staging" else "ckcz6bubudyfi0855o1dt1g9s" + workforce_id = _get_workforce_id_for_test() external_workforces = project.add_external_workforce(workforce_id) assert len(external_workforces) == 1 @@ -12,14 +22,18 @@ def test_add_external_workforce(project: Project): def test_get_external_workforces(project: Project): + workforce_id = _get_workforce_id_for_test() + + external_workforces = project.add_external_workforce(workforce_id) + external_workforces = project.get_external_workforces() assert len(external_workforces) == 1 assert isinstance(external_workforces[0], ExternalWorkforce) -def test_remove_external_workforce(project: Project): - # org id from https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests - workforce_id = "clum46jsb00jp07z338dh1gvb" if os.environ['LABELBOX_TEST_ENVIRON'] == "staging" else "ckcz6bubudyfi0855o1dt1g9s" +def test_remove_external_workforce(project: Project): + workforce_id = _get_workforce_id_for_test() + external_workforces = project.add_external_workforce(workforce_id) external_workforces = project.remove_external_workforce(workforce_id) assert len(external_workforces) == 0 From 2382f6df68960fd1bc5d1debab755fbc49250d0f Mon Sep 17 00:00:00 2001 From: paulnoirel <87332996+paulnoirel@users.noreply.github.com> Date: Thu, 20 Jun 2024 10:36:27 +0100 Subject: [PATCH 6/6] Remove trailing whitespace, change exception, add test comments --- libs/labelbox/src/labelbox/schema/project.py | 35 ++++++++++--------- .../integration/test_external_workforce.py | 27 ++++++++++---- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index 96c553d5e..d73912e28 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -30,6 +30,7 @@ from labelbox.schema.export_filters import ProjectExportFilters, validate_datetime, build_filters from labelbox.schema.export_params import ProjectExportParams from labelbox.schema.export_task import ExportTask +from labelbox.schema.external_workforce import ExternalWorkforce from labelbox.schema.id_type import IdType from labelbox.schema.identifiable import DataRowIdentifier, GlobalKey, UniqueId from labelbox.schema.identifiables import DataRowIdentifiers, UniqueIds @@ -42,7 +43,7 @@ from labelbox.schema.task_queue import TaskQueue from labelbox.schema.ontology_kind import (EditorTaskType, OntologyKind) from labelbox.schema.project_overview import ProjectOverview, ProjectOverviewDetailed -from labelbox.schema.external_workforce import ExternalWorkforce + if TYPE_CHECKING: from labelbox import BulkImportRequest @@ -1772,7 +1773,7 @@ def get_overview(self, details=False) -> Union[ProjectOverview, ProjectOverviewD """ query = """query ProjectGetOverviewPyApi($projectId: ID!) { - project(where: { id: $projectId }) { + project(where: { id: $projectId }) { workstreamStateCounts { state count @@ -1805,7 +1806,7 @@ def get_overview(self, details=False) -> Union[ProjectOverview, ProjectOverviewD # Rename categories overview["to_label"] = overview.pop("unlabeled") - overview["total_data_rows"] = overview.pop("all") + overview["total_data_rows"] = overview.pop("all") if not details: return ProjectOverview(**overview) @@ -1822,9 +1823,9 @@ def get_overview(self, details=False) -> Union[ProjectOverview, ProjectOverviewD "data": queues, "total": overview[f"in_{category}"] } - + return ProjectOverviewDetailed(**overview) - + def clone(self) -> "Project": """ Clones the current project. @@ -1841,9 +1842,9 @@ def clone(self) -> "Project": """ result = self.client.execute(mutation, {"projectId": self.uid}) return self.client.get_project(result["cloneProject"]["id"]) - + def add_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) -> List[ExternalWorkforce]: - + """ Add an external workforce (organization) to a project. @@ -1857,7 +1858,7 @@ def add_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) -> LabelboxError: If the external workforce with the given ID cannot be found. Note: - This method adds an external workforce (organization) to the current project. + This method adds an external workforce (organization) to the current project. The `workforce_id` parameter can be either a string representing the organization ID or an instance of the `ExternalWorkforce` class. """ workforce_id = workforce_id.uid if isinstance(workforce_id, ExternalWorkforce) else workforce_id @@ -1879,10 +1880,10 @@ def add_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) -> result = self.client.execute(mutation, {"projectId": self.uid, "organizationId": workforce_id}) if not result: - raise LabelboxError(f"Can't find External Workforce {workforce_id}") + raise ResourceNotFoundError(ExternalWorkforce, {"id": workforce_id}) - return [ExternalWorkforce(**workforce_dic) - for workforce_dic + return [ExternalWorkforce(**workforce_dic) + for workforce_dic in result["shareProjectWithExternalOrganization"]["sharedWithOrganizations"]] @@ -1914,14 +1915,14 @@ def remove_external_workforce(self, workforce_id: Union[str, ExternalWorkforce]) } } """ - + result = self.client.execute(mutation, {"projectId": self.uid, "organizationId": workforce_id}) - + if not result: - raise LabelboxError(f"Can't find External Workforce {workforce_id}") + raise ResourceNotFoundError(ExternalWorkforce, {"id": workforce_id}) - return [ExternalWorkforce(**workforce_dic) - for workforce_dic + return [ExternalWorkforce(**workforce_dic) + for workforce_dic in result["unshareProjectWithExternalOrganization"]["sharedWithOrganizations"]] @@ -1949,7 +1950,7 @@ def get_external_workforces(self) -> List[ExternalWorkforce]: """ result = self.client.execute(query, {"projectId": self.uid})["project"]["sharedWithOrganizations"] - return [ExternalWorkforce(**workforce_dic) for workforce_dic in result] + return [ExternalWorkforce(**workforce_dic) for workforce_dic in result] class ProjectMember(DbObject): diff --git a/libs/labelbox/tests/integration/test_external_workforce.py b/libs/labelbox/tests/integration/test_external_workforce.py index f408b76bb..7e0a28e99 100644 --- a/libs/labelbox/tests/integration/test_external_workforce.py +++ b/libs/labelbox/tests/integration/test_external_workforce.py @@ -3,19 +3,34 @@ import sys def _get_workforce_id_for_test() -> str: - # Basic function to provide an organization id for the test - # org id from https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests - current_version = sys.version_info[:2] + # For this test, we need an additional organization id to connect the project to. + # Given that the SDK is tested against multiple versions of Python, and different environments, + # we decided to choose the organization id from + # https://labelbox.atlassian.net/wiki/spaces/PLT/pages/2110816271/How+to+labelbox-python+SDK+CI+Tests + # + # In particular, we have: + # clum46jsb00jp07z338dh1gvb: for Python 3.8 in staging + # cltp16p2v04dr07ywfgqxf23u: for Python 3.12 in staging + # ckcz6bubudyfi0855o1dt1g9s: for Python 3.8 in production + # cltp1fral01dh07009zsng3zs: for Python 3.12 in production + # + # The idea is that when depending on the environment, if the current version of Python is 3.8, + # then we use the organization id for Python 3.12. This way, We always us a different organization id + # from the current one as an external workforce to avoid conflict. + + # Note: A better approach would be to create a new organization dynamically for testing purpose. + # This is not currently possible. + current_version = sys.version_info[:2] if os.environ['LABELBOX_TEST_ENVIRON'] == "staging": return "cltp16p2v04dr07ywfgqxf23u" if current_version == (3, 8) else "clum46jsb00jp07z338dh1gvb" else: return "cltp1fral01dh07009zsng3zs" if current_version == (3, 8) else "ckcz6bubudyfi0855o1dt1g9s" - + def test_add_external_workforce(project: Project): workforce_id = _get_workforce_id_for_test() - + external_workforces = project.add_external_workforce(workforce_id) assert len(external_workforces) == 1 assert isinstance(external_workforces[0], ExternalWorkforce) @@ -31,7 +46,7 @@ def test_get_external_workforces(project: Project): assert isinstance(external_workforces[0], ExternalWorkforce) -def test_remove_external_workforce(project: Project): +def test_remove_external_workforce(project: Project): workforce_id = _get_workforce_id_for_test() external_workforces = project.add_external_workforce(workforce_id)