From 3f8cabb6d7e28f1dd4c745a7a903459c9713cdad Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 2 Jul 2025 12:43:11 +0400 Subject: [PATCH 01/12] added project category related functions --- docs/source/api_reference/api_project.rst | 3 + .../lib/app/interface/sdk_interface.py | 150 ++++++++++++++++++ src/superannotate/lib/core/entities/items.py | 9 ++ src/superannotate/lib/core/service_types.py | 4 + .../lib/core/serviceproviders.py | 16 +- .../lib/core/usecases/annotations.py | 4 +- .../lib/infrastructure/controller.py | 12 ++ .../services/work_management.py | 37 ++++- .../test_project_categories.py | 146 +++++++++++++++++ 9 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 tests/integration/work_management/test_project_categories.py diff --git a/docs/source/api_reference/api_project.rst b/docs/source/api_reference/api_project.rst index 858030333..23f0cdac5 100644 --- a/docs/source/api_reference/api_project.rst +++ b/docs/source/api_reference/api_project.rst @@ -27,3 +27,6 @@ Projects .. automethod:: superannotate.SAClient.get_project_steps .. automethod:: superannotate.SAClient.set_project_steps .. automethod:: superannotate.SAClient.get_component_config +.. automethod:: superannotate.SAClient.create_categories +.. automethod:: superannotate.SAClient.list_categories +.. automethod:: superannotate.SAClient.remove_categories diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 8bcc3d924..856a3f2a6 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -73,6 +73,8 @@ from lib.app.serializers import WMProjectSerializer from lib.core.entities.work_managament import WMUserTypeEnum from lib.core.jsx_conditions import EmptyQuery +from lib.core.entities.items import ProjectCategoryEntity + logger = logging.getLogger("sa") @@ -1194,6 +1196,154 @@ def clone_project( ) return data + def create_categories( + self, project: Union[NotEmptyStr, int], categories: List[str] + ): + """ + Create one or more categories in a project. + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param categories: A list of categories to create + :type categories: list of str + + Request Example: + :: + + client.create_categories( + project="product-review-mm", + categories=["Shoes", "T-Shirt"] + ) + """ + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.check_multimodal_project_categorization(project) + + response = ( + self.controller.service_provider.work_management.create_project_categories( + project_id=project.id, categories=categories + ) + ) + logger.info( + f"{len(response.data)} categories successfully added to the project." + ) + + def list_categories(self, project: Union[NotEmptyStr, int]): + """ + List all categories in the project. + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :return: List of categories + :rtype: list of dict + + Request Example: + :: + + client.list_categories( + project="product-review-mm" + ) + + Response Example: + :: + + [ + { + "createdAt": "2025-01-29T13:51:39.000Z", + "updatedAt": "2025-01-29T13:51:39.000Z", + "id": 328577, + "name": "category1", + "project_id": 1234 + }, + { + "createdAt": "2025-01-29T13:51:39.000Z", + "updatedAt": "2025-01-29T13:51:39.000Z", + "id": 328577, + "name": "category2", + "project_id": 1234 + }, + ] + + """ + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.check_multimodal_project_categorization(project) + + response = ( + self.controller.service_provider.work_management.list_project_categories( + project_id=project.id, entity=ProjectCategoryEntity + ) + ) + return BaseSerializer.serialize_iterable(response.data) + + def remove_categories( + self, + project: Union[NotEmptyStr, int], + categories: Union[List[str], Literal["*"]], + ): + """ + Remove one or more categories in a project. "*" in the category list will match all categories defined in the project. + + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param categories: A list of categories to remove, Accepts "*" to indicate all available categories in the project. + :type categories: Union[List[str], Literal["*"]] + + Request Example: + :: + + client.remove_categories( + project="product-review-mm", + categories=["Shoes", "T-Shirt"] + ) + + # To remove all categories + client.remove_categories( + project="product-review-mm", + categories="*" + ) + """ + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.check_multimodal_project_categorization(project) + + query = EmptyQuery() + if categories == "*": + query &= Filter("id", [0], OperatorEnum.GT) + elif categories and isinstance(categories, list): + categories = [c.lower() for c in categories] + all_categories = self.controller.service_provider.work_management.list_project_categories( + project_id=project.id, entity=ProjectCategoryEntity + ) + categories_to_remove = [ + c for c in all_categories.data if c.name.lower() in categories + ] + query &= Filter("id", [c.id for c in categories_to_remove], OperatorEnum.IN) + else: + raise AppException("Categories should be a list of strings or '*'.") + + response = ( + self.controller.service_provider.work_management.remove_project_categories( + project_id=project.id, query=query + ) + ) + logger.info( + f"{len(response.data)} categories successfully removed from the project." + ) + def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr): """ Create a new folder in the project. diff --git a/src/superannotate/lib/core/entities/items.py b/src/superannotate/lib/core/entities/items.py index 477cab8dd..1beff24cf 100644 --- a/src/superannotate/lib/core/entities/items.py +++ b/src/superannotate/lib/core/entities/items.py @@ -26,6 +26,15 @@ class Config: extra = Extra.ignore +class ProjectCategoryEntity(TimedBaseModel): + id: int + name: str + project_id: int + + class Config: + extra = Extra.ignore + + class MultiModalItemCategoryEntity(TimedBaseModel): id: int = Field(None, alias="category_id") value: str = Field(None, alias="category_name") diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index e98c4af62..066def7b7 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -234,6 +234,10 @@ class ListCategoryResponse(ServiceResponse): res_data: List[entities.CategoryEntity] = None +class ListProjectCategoryResponse(ServiceResponse): + res_data: List[entities.items.ProjectCategoryEntity] = None + + class WorkflowResponse(ServiceResponse): res_data: entities.WorkflowEntity = None diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 9ffba499f..95b313f13 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -7,9 +7,12 @@ from typing import List from typing import Literal from typing import Optional +from typing import Union from lib.core import entities from lib.core.conditions import Condition +from lib.core.entities import CategoryEntity +from lib.core.entities.project_entities import BaseEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.jsx_conditions import Query from lib.core.reporter import Reporter @@ -18,6 +21,7 @@ from lib.core.service_types import FolderResponse from lib.core.service_types import IntegrationListResponse from lib.core.service_types import ListCategoryResponse +from lib.core.service_types import ListProjectCategoryResponse from lib.core.service_types import ProjectListResponse from lib.core.service_types import ProjectResponse from lib.core.service_types import ServiceResponse @@ -137,13 +141,21 @@ def search_projects( raise NotImplementedError @abstractmethod - def list_project_categories(self, project_id: int) -> ListCategoryResponse: + def list_project_categories( + self, project_id: int, entity: BaseEntity = CategoryEntity + ) -> Union[ListCategoryResponse, ListProjectCategoryResponse]: + raise NotImplementedError + + @abstractmethod + def remove_project_categories( + self, project_id: int, query: Query + ) -> ListProjectCategoryResponse: raise NotImplementedError @abstractmethod def create_project_categories( self, project_id: int, categories: List[str] - ) -> ServiceResponse: + ) -> ListProjectCategoryResponse: raise NotImplementedError @abstractmethod diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 854f1ebd0..0e5365e52 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -2165,10 +2165,10 @@ def _attach_categories(self, folder_id: int, item_id_category_map: Dict[int, str self._service_provider.work_management.create_project_categories( project_id=self._project.id, categories=categories_to_create, - ).data["data"] + ).data ) for c in _categories: - self._category_name_to_id_map[c["name"]] = c["id"] + self._category_name_to_id_map[c.name] = c.id for item_id, category_name in item_id_category_map.items(): with suppress(KeyError): item_id_category_id_map[item_id] = self._category_name_to_id_map[ diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 48e6cea63..ae361ee39 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1918,3 +1918,15 @@ def get_item( return self.get_item_by_id(item_id=item, project=project) else: return self.items.get_by_name(project, folder, item) + + def check_multimodal_project_categorization(self, project: ProjectEntity): + if project.type != ProjectType.MULTIMODAL: + raise AppException( + "This function is only supported for Multimodal projects." + ) + project_settings = self.service_provider.projects.list_settings(project).data + if not next( + (i.value for i in project_settings if i.attribute == "CategorizeItems"), + None, + ): + raise AppException("Item Category not enabled for project.") diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 375a6932b..2edf6fcec 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -3,9 +3,11 @@ from typing import List from typing import Literal from typing import Optional +from typing import Union from lib.core.entities import CategoryEntity from lib.core.entities import WorkflowEntity +from lib.core.entities.project_entities import BaseEntity from lib.core.entities.work_managament import WMProjectEntity from lib.core.entities.work_managament import WMProjectUserEntity from lib.core.entities.work_managament import WMScoreEntity @@ -16,6 +18,7 @@ from lib.core.jsx_conditions import OperatorEnum from lib.core.jsx_conditions import Query from lib.core.service_types import ListCategoryResponse +from lib.core.service_types import ListProjectCategoryResponse from lib.core.service_types import ServiceResponse from lib.core.service_types import WMCustomFieldResponse from lib.core.service_types import WMProjectListResponse @@ -74,10 +77,12 @@ def _generate_context(**kwargs): encoded_context = base64.b64encode(json.dumps(kwargs).encode("utf-8")) return encoded_context.decode("utf-8") - def list_project_categories(self, project_id: int) -> ListCategoryResponse: - return self.client.paginate( - self.URL_LIST_CATEGORIES, - item_type=CategoryEntity, + def list_project_categories( + self, project_id: int, entity: BaseEntity = CategoryEntity + ) -> Union[ListCategoryResponse, ListProjectCategoryResponse]: + response = self.client.paginate( + url=self.URL_LIST_CATEGORIES, + item_type=entity, query_params={"project_id": project_id}, headers={ "x-sa-entity-context": self._generate_context( @@ -85,10 +90,12 @@ def list_project_categories(self, project_id: int) -> ListCategoryResponse: ), }, ) + response.raise_for_status() + return response def create_project_categories( self, project_id: int, categories: List[str] - ) -> ServiceResponse: + ) -> ListProjectCategoryResponse: response = self.client.request( method="post", url=self.URL_CREATE_CATEGORIES, @@ -99,6 +106,26 @@ def create_project_categories( team_id=self.client.team_id, project_id=project_id ), }, + content_type=ListProjectCategoryResponse, + dispatcher="data", + ) + response.raise_for_status() + return response + + def remove_project_categories( + self, project_id: int, query: Query + ) -> ListProjectCategoryResponse: + + response = self.client.request( + method="delete", + url=f"{self.URL_CREATE_CATEGORIES}?{query.build_query()}", + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id, project_id=project_id + ), + }, + content_type=ListProjectCategoryResponse, + dispatcher="data", ) response.raise_for_status() return response diff --git a/tests/integration/work_management/test_project_categories.py b/tests/integration/work_management/test_project_categories.py new file mode 100644 index 000000000..fbb55ec34 --- /dev/null +++ b/tests/integration/work_management/test_project_categories.py @@ -0,0 +1,146 @@ +import json +import os +import time +from pathlib import Path +from unittest import TestCase + +from src.superannotate import SAClient + +sa = SAClient() + + +class TestProjectCategories(TestCase): + PROJECT_NAME = "TestProjectCategories" + PROJECT_TYPE = "Multimodal" + PROJECT_DESCRIPTION = "DESCRIPTION" + EDITOR_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1.json", + ) + CLASSES_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1_classes.json", + ) + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + cls.tearDownClass() + cls._project = sa.create_project( + cls.PROJECT_NAME, + cls.PROJECT_DESCRIPTION, + cls.PROJECT_TYPE, + settings=[ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 1}, + ], + ) + team = sa.controller.team + project = sa.controller.get_project(cls.PROJECT_NAME) + time.sleep(5) + + with open(cls.EDITOR_TEMPLATE_PATH) as f: + template_data = json.load(f) + res = sa.controller.service_provider.projects.attach_editor_template( + team, project, template=template_data + ) + assert res.ok + sa.create_annotation_classes_from_classes_json( + cls.PROJECT_NAME, cls.CLASSES_TEMPLATE_PATH + ) + + @classmethod + def tearDownClass(cls) -> None: + # cleanup test scores and project + projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + for project in projects: + try: + sa.delete_project(project) + except Exception: + pass + + def test_project_categories_flow(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.create_categories( + project=self.PROJECT_NAME, + categories=["SDK_test_category_1", "SDK_test_category_2"], + ) + assert ( + "INFO:sa:2 categories successfully added to the project." + == cm.output[0] + ) + categories = sa.list_categories(project=self.PROJECT_NAME) + assert len(categories) == 2 + assert categories[0]["name"] == "SDK_test_category_1" + assert categories[1]["name"] == "SDK_test_category_2" + + # Check that each category has the expected keys + for category in categories: + assert "id" in category + assert "project_id" in category + assert "createdAt" in category + assert "updatedAt" in category + + # delete categories + with self.assertLogs("sa", level="INFO") as cm: + sa.remove_categories(project=self.PROJECT_NAME, categories="*") + assert ( + "INFO:sa:2 categories successfully removed from the project." + == cm.output[0] + ) + categories = sa.list_categories(project=self.PROJECT_NAME) + assert not categories + + def test_duplicate_categories_handling(self): + sa.create_categories( + project=self.PROJECT_NAME, + categories=[ + "Category_A", + "Category_B", + "category_a", + "Category_B", + "Category_A", + ], + ) + # Verify only unique categories were created + categories = sa.list_categories(project=self.PROJECT_NAME) + category_names = [category["name"] for category in categories] + + # Should only have two categories (first occurrences of each unique name) + assert len(categories) == 2, f"Expected 2 categories, got {len(categories)}" + assert ( + "Category_A" in category_names + ), "Category_A not found in created categories" + assert ( + "Category_B" in category_names + ), "Category_B not found in created categories" + assert ( + "category_a" not in category_names + ), "Duplicate category_a should not be created" + # Clean up + sa.remove_categories(project=self.PROJECT_NAME, categories="*") + + def test_category_name_length_limitation(self): + long_name = "A" * 250 # 250 characters + expected_truncated_length = 200 # Expected length after truncation + + # Create the category with the long name + sa.create_categories(project=self.PROJECT_NAME, categories=[long_name]) + + categories = sa.list_categories(project=self.PROJECT_NAME) + assert len(categories) == 1, "Expected 1 category to be created" + + created_category = categories[0] + assert len(created_category["name"]) == expected_truncated_length + assert created_category["name"] == long_name[:expected_truncated_length] + # Clean up + sa.remove_categories(project=self.PROJECT_NAME, categories="*") + + def test_delete_all_categories_with_asterisk(self): + sa.create_categories( + project=self.PROJECT_NAME, categories=["Cat1", "Cat2", "Cat3"] + ) + categories = sa.list_categories(project=self.PROJECT_NAME) + assert len(categories) == 3 + sa.remove_categories(project=self.PROJECT_NAME, categories="*") + categories = sa.list_categories(project=self.PROJECT_NAME) + assert len(categories) == 0 From c7a8ec9a10df2fd4515975503197b1e1717d41e3 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Fri, 4 Jul 2025 15:12:05 +0400 Subject: [PATCH 02/12] added item category set/remove functions --- docs/source/api_reference/api_item.rst | 2 + .../lib/app/interface/sdk_interface.py | 67 +++++++++ .../lib/core/serviceproviders.py | 10 +- src/superannotate/lib/core/usecases/items.py | 51 +++++++ .../lib/infrastructure/controller.py | 43 +++++- .../lib/infrastructure/services/item.py | 23 ++- .../integration/items/test_attach_category.py | 142 ++++++++++++++++++ 7 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 tests/integration/items/test_attach_category.py diff --git a/docs/source/api_reference/api_item.rst b/docs/source/api_reference/api_item.rst index 0947ed7b2..e25f57894 100644 --- a/docs/source/api_reference/api_item.rst +++ b/docs/source/api_reference/api_item.rst @@ -21,3 +21,5 @@ Items .. automethod:: superannotate.SAClient.unassign_items .. automethod:: superannotate.SAClient.get_item_metadata .. automethod:: superannotate.SAClient.set_approval_statuses +.. automethod:: superannotate.SAClient.set_items_category +.. automethod:: superannotate.SAClient.remove_items_category diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 856a3f2a6..dec26352c 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4393,6 +4393,73 @@ def move_items( raise AppException(response.errors) return response.data + def set_items_category( + self, + project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + items: List[Union[int, str]], + category: str, + ): + """ + Add categories to one or more items. + + :param project: Project and folder as a tuple, folder is optional. + :type project: Union[str, Tuple[int, int], Tuple[str, str]] + + :param items: A list of names or IDs of the items to modify. + :type items: List[Union[int, str]] + + :param category: Category to assign to the item. + :type category: Str + + Request Example: + :: + + client.set_items_category( + project=("product-review-mm", "folder1"), + items=[112233, 112344], + category="Shoes" + ) + """ + project, folder = self.controller.get_project_folder(project) + self.controller.check_multimodal_project_categorization(project) + + self.controller.items.attach_detach_items_category( + project=project, + folder=folder, + items=items, + category=category, + operation="attach", + ) + + def remove_items_category( + self, + project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], + items: List[Union[int, str]], + ): + """ + Remove categories from one or more items. + + :param project: Project and folder as a tuple, folder is optional. + :type project: Union[str, Tuple[int, int], Tuple[str, str]] + + :param items: A list of names or IDs of the items to modify. + :type items: List[Union[int, str]] + + Request Example: + :: + + client.remove_items_category( + project=("product-review-mm", "folder1"), + items=[112233, 112344] + ) + """ + project, folder = self.controller.get_project_folder(project) + self.controller.check_multimodal_project_categorization(project) + + self.controller.items.attach_detach_items_category( + project=project, folder=folder, items=items, operation="detach" + ) + def set_annotation_statuses( self, project: Union[NotEmptyStr, dict], diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 95b313f13..6672383bc 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -499,8 +499,14 @@ def delete_multiple( @abstractmethod def bulk_attach_categories( - self, project_id: int, folder_id: int, item_category_map: Dict[int, int] - ) -> bool: + self, project_id: int, folder_id: int, item_id_category_id_map: Dict[int, int] + ) -> ServiceResponse: + raise NotImplementedError + + @abstractmethod + def bulk_detach_categories( + self, project_id: int, folder_id: int, item_ids: List[int] + ) -> ServiceResponse: raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 10fc9e006..fe9a64918 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -7,6 +7,7 @@ from typing import Dict from typing import Generator from typing import List +from typing import Optional from typing import Union import lib.core as constants @@ -20,6 +21,7 @@ from lib.core.entities import ProjectEntity from lib.core.entities import VideoEntity from lib.core.entities.items import MultiModalItemEntity +from lib.core.entities.items import ProjectCategoryEntity from lib.core.exceptions import AppException from lib.core.exceptions import AppValidationException from lib.core.exceptions import BackendError @@ -38,6 +40,7 @@ from lib.infrastructure.utils import extract_project_folder from typing_extensions import Literal + logger = logging.getLogger("sa") @@ -1272,3 +1275,51 @@ def execute( # returning control to the interface function that called it. So no need for # error handling in the response return self._response + + +class AttacheDetachItemsCategoryUseCase(BaseUseCase): + CHUNK_SIZE = 2000 + + def __init__( + self, + project: ProjectEntity, + folder: FolderEntity, + items: List[MultiModalItemEntity], + service_provider: BaseServiceProvider, + operation: Literal["attach", "detach"], + category: Optional[ProjectCategoryEntity] = None, + ): + super().__init__() + self._project = project + self._folder = folder + self._items = items + self._category = category + self._operation = operation + self._service_provider = service_provider + + def execute(self): + if self._operation == "attach": + success_count = 0 + for chunk in divide_to_chunks(self._items, self.CHUNK_SIZE): + item_id_category_id_map: Dict[int, int] = { + i.id: self._category.id for i in chunk + } + response = self._service_provider.items.bulk_attach_categories( + project_id=self._project.id, + folder_id=self._folder.id, + item_id_category_id_map=item_id_category_id_map, + ) + success_count += len(response.data) + logger.info( + f"{self._category.name} category successfully added to {success_count} items." + ) + elif self._operation == "detach": + success_count = 0 + for chunk in divide_to_chunks(self._items, self.CHUNK_SIZE): + response = self._service_provider.items.bulk_detach_categories( + project_id=self._project.id, + folder_id=self._folder.id, + item_ids=[i.id for i in chunk], + ) + success_count += len(response.data) + logger.info(f"Category successfully removed from {success_count} items.") diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index ae361ee39..dd531a324 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -35,6 +35,7 @@ from lib.core.entities.filters import ProjectFilters from lib.core.entities.filters import UserFilters from lib.core.entities.integrations import IntegrationEntity +from lib.core.entities.items import ProjectCategoryEntity from lib.core.entities.work_managament import ScoreEntity from lib.core.entities.work_managament import ScorePayloadEntity from lib.core.enums import CustomFieldEntityEnum @@ -877,7 +878,7 @@ def list_items( folder: FolderEntity, /, include: List[str] = None, - **filters: Unpack[ItemFilters], + **filters: Optional[Unpack[ItemFilters]], ) -> List[BaseItemEntity]: entity = PROJECT_ITEM_ENTITY_MAP.get(project.type, BaseItemEntity) @@ -1062,6 +1063,46 @@ def update(self, project: ProjectEntity, item: BaseItemEntity): ) return use_case.execute() + def attach_detach_items_category( + self, + project: ProjectEntity, + folder: FolderEntity, + items: List[Union[int, str]], + operation: Literal["attach", "detach"], + category: Optional[str] = None, + ): + if items and isinstance(items[0], str): + items = self.list_items(project, folder, name__in=items) + elif items and isinstance(items[0], int): + items = self.list_items(project, folder, id__in=items) + else: + raise AppException( + "Items must be a list of strings or integers representing item IDs." + ) + + if category: + all_categories = ( + self.service_provider.work_management.list_project_categories( + project.id, ProjectCategoryEntity # noqa + ) + ) + category = next( + (c for c in all_categories.data if c.name.lower() == category.lower()), + None, + ) + if not category: + raise AppException("Category not defined in project.") + + use_case = usecases.AttacheDetachItemsCategoryUseCase( + project=project, + folder=folder, + items=items, + category=category, + operation=operation, + service_provider=self.service_provider, + ) + return use_case.execute() + class AnnotationManager(BaseManager): def __init__(self, service_provider: ServiceProvider, config: ConfigEntity): diff --git a/src/superannotate/lib/infrastructure/services/item.py b/src/superannotate/lib/infrastructure/services/item.py index d99d37b59..828802cbf 100644 --- a/src/superannotate/lib/infrastructure/services/item.py +++ b/src/superannotate/lib/infrastructure/services/item.py @@ -217,7 +217,7 @@ def delete_multiple(self, project: entities.ProjectEntity, item_ids: List[int]): ) def bulk_attach_categories( - self, project_id: int, folder_id: int, item_category_map: Dict[int, int] + self, project_id: int, folder_id: int, item_id_category_id_map: Dict[int, int] ) -> bool: params = {"project_id": project_id, "folder_id": folder_id} response = self.client.request( @@ -226,10 +226,25 @@ def bulk_attach_categories( params=params, data={ "bulk": [ - {"item_id": item_id, "categories": [category]} - for item_id, category in item_category_map.items() + {"item_id": item_id, "categories": [category_id]} + for item_id, category_id in item_id_category_id_map.items() ] }, ) response.raise_for_status() - return response.ok + return response + + def bulk_detach_categories( + self, project_id: int, folder_id: int, item_ids: List[int] + ) -> bool: + params = {"project_id": project_id, "folder_id": folder_id} + response = self.client.request( + self.URL_ATTACH_CATEGORIES, + "post", + params=params, + data={ + "bulk": [{"item_id": item_id, "categories": []} for item_id in item_ids] + }, + ) + response.raise_for_status() + return response diff --git a/tests/integration/items/test_attach_category.py b/tests/integration/items/test_attach_category.py new file mode 100644 index 000000000..eb812db38 --- /dev/null +++ b/tests/integration/items/test_attach_category.py @@ -0,0 +1,142 @@ +import json +import os +import time +from pathlib import Path +from unittest import TestCase + +from src.superannotate import SAClient + +sa = SAClient() + + +class TestItemAttachCategory(TestCase): + PROJECT_NAME = "TestItemAttachCategory" + PROJECT_TYPE = "Multimodal" + PROJECT_DESCRIPTION = "DESCRIPTION" + EDITOR_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1.json", + ) + CLASSES_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1_classes.json", + ) + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + cls.tearDownClass() + cls._project = sa.create_project( + cls.PROJECT_NAME, + cls.PROJECT_DESCRIPTION, + cls.PROJECT_TYPE, + settings=[ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 1}, + ], + ) + team = sa.controller.team + project = sa.controller.get_project(cls.PROJECT_NAME) + time.sleep(5) + + with open(cls.EDITOR_TEMPLATE_PATH) as f: + template_data = json.load(f) + res = sa.controller.service_provider.projects.attach_editor_template( + team, project, template=template_data + ) + assert res.ok + sa.create_annotation_classes_from_classes_json( + cls.PROJECT_NAME, cls.CLASSES_TEMPLATE_PATH + ) + + @classmethod + def tearDownClass(cls) -> None: + # cleanup test scores and project + projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + for project in projects: + try: + sa.delete_project(project) + except Exception: + pass + + @staticmethod + def _attach_items(path: str, names: list[str]): + sa.attach_items(path, [{"name": name, "url": f"url-{name}"} for name in names]) + + def test_attache_category(self): + self._attach_items(self.PROJECT_NAME, ["item-1", "item-2"]) + sa.create_categories(self.PROJECT_NAME, ["category-1", "category-2"]) + + with self.assertLogs("sa", level="INFO") as cm: + sa.set_items_category(self.PROJECT_NAME, ["item-1", "item-2"], "category-1") + assert ( + "INFO:sa:category-1 category successfully added to 2 items." + == cm.output[0] + ) + + items = sa.list_items(self.PROJECT_NAME, include=["categories"]) + assert all(i["categories"][0]["value"] == "category-1" for i in items) + + def test_remove_items_category(self): + self._attach_items(self.PROJECT_NAME, ["item-1", "item-2", "item-3"]) + sa.create_categories(self.PROJECT_NAME, ["category-1", "category-2"]) + sa.set_items_category( + self.PROJECT_NAME, ["item-1", "item-2", "item-3"], "category-1" + ) + + items = sa.list_items(self.PROJECT_NAME, include=["categories"]) + assert len(items) == 3 + assert all( + len(i["categories"]) == 1 and i["categories"][0]["value"] == "category-1" + for i in items + ) + + with self.assertLogs("sa", level="INFO") as cm: + sa.remove_items_category(self.PROJECT_NAME, ["item-1", "item-2"]) + assert "INFO:sa:Category successfully removed from 2 items." == cm.output[0] + + items = sa.list_items(self.PROJECT_NAME, include=["categories"]) + item_dict = {item["name"]: item for item in items} + + assert len(item_dict["item-1"]["categories"]) == 0 + assert len(item_dict["item-2"]["categories"]) == 0 + + assert len(item_dict["item-3"]["categories"]) == 1 + assert item_dict["item-3"]["categories"][0]["value"] == "category-1" + + def test_remove_items_category_by_ids(self): + self._attach_items(self.PROJECT_NAME, ["item-4", "item-5"]) + sa.create_categories(self.PROJECT_NAME, ["category-test"]) + sa.set_items_category(self.PROJECT_NAME, ["item-4", "item-5"], "category-test") + + items = sa.list_items(self.PROJECT_NAME, include=["categories"]) + item_ids = [ + item["id"] for item in items if item["name"] in ["item-4", "item-5"] + ] + + sa.remove_items_category(self.PROJECT_NAME, item_ids) + items = sa.list_items(self.PROJECT_NAME, include=["categories"]) + for item in items: + if item["name"] in ["item-4", "item-5"]: + assert len(item["categories"]) == 0 + + def test_remove_items_category_with_folder(self): + folder_name = "test-folder" + sa.create_folder(self.PROJECT_NAME, folder_name) + folder_path = f"{self.PROJECT_NAME}/{folder_name}" + self._attach_items(folder_path, ["folder-item-1", "folder-item-2"]) + + sa.create_categories(self.PROJECT_NAME, ["folder-category"]) + sa.set_items_category( + folder_path, ["folder-item-1", "folder-item-2"], "folder-category" + ) + + sa.remove_items_category(folder_path, ["folder-item-1"]) + + items = sa.list_items( + project=self.PROJECT_NAME, folder=folder_name, include=["categories"] + ) + item_dict = {item["name"]: item for item in items} + + assert len(item_dict["folder-item-1"]["categories"]) == 0 + assert len(item_dict["folder-item-2"]["categories"]) == 1 + assert item_dict["folder-item-2"]["categories"][0]["value"] == "folder-category" From dedc07998e7ed1509b5fd7a918179b38579099fa Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 9 Jul 2025 14:38:14 +0400 Subject: [PATCH 03/12] added contributor categories functions --- docs/source/api_reference/api_team.rst | 2 + .../lib/app/interface/sdk_interface.py | 116 +++++++- .../lib/core/entities/work_managament.py | 3 +- .../lib/core/serviceproviders.py | 11 + .../lib/infrastructure/controller.py | 82 +++++- .../services/work_management.py | 45 +++ .../test_contributors_categories.py | 266 ++++++++++++++++++ 7 files changed, 515 insertions(+), 10 deletions(-) create mode 100644 tests/integration/work_management/test_contributors_categories.py diff --git a/docs/source/api_reference/api_team.rst b/docs/source/api_reference/api_team.rst index 15f8130c2..2217365e1 100644 --- a/docs/source/api_reference/api_team.rst +++ b/docs/source/api_reference/api_team.rst @@ -15,3 +15,5 @@ Team .. automethod:: superannotate.SAClient.resume_user_activity .. automethod:: superannotate.SAClient.get_user_scores .. automethod:: superannotate.SAClient.set_user_scores +.. automethod:: superannotate.SAClient.set_contributors_categories +.. automethod:: superannotate.SAClient.remove_contributors_categories diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index dec26352c..f6beab25d 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -474,7 +474,7 @@ def list_users( self, *, project: Union[int, str] = None, - include: List[Literal["custom_fields"]] = None, + include: List[Literal["custom_fields", "categories"]] = None, **filters, ): """ @@ -488,7 +488,10 @@ def list_users( Possible values are - - "custom_fields": Includes custom fields and scores assigned to each user. + - "custom_fields": Includes custom fields and scores assigned to each user. + - "categories": Includes a list of categories assigned to each project contributor. + Note: 'project' parameter must be specified when including 'categories'. + :type include: list of str, optional :param filters: Specifies filtering criteria, with all conditions combined using logical AND. @@ -860,6 +863,103 @@ def set_user_scores( ) logger.info("Scores successfully set.") + def set_contributors_categories( + self, + project: Union[NotEmptyStr, int], + contributors: List[Union[int, str]], + categories: Union[List[str], Literal["*"]], + ): + """ + Assign one or more categories to a contributor with an assignable role (Annotator, QA or custom role) + in a Multimodal project. Project Admins are not eligible for category assignments. "*" in the category + list will match all categories defined in the project. + + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param contributors: A list of emails or IDs of the contributor. + :type contributors: List[Union[int, str]] + + :param categories: A list of category names to assign. Accepts "*" to indicate all available categories in the project. + :type categories: Union[List[str], Literal["*"]] + + Request Example: + :: + + client.set_contributor_categories( + project="product-review-mm", + contributors=["test@superannotate.com","contributor@superannotate.com"], + categories=["Shoes", "T-Shirt"] + ) + + client.set_contributor_categories( + project="product-review-mm", + contributors=["test@superannotate.com","contributor@superannotate.com"] + categories="*" + ) + """ + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.check_multimodal_project_categorization(project) + + self.controller.work_management.set_remove_contributor_categories( + project=project, + contributors=contributors, + categories=categories, + operation="set", + ) + + def remove_contributors_categories( + self, + project: Union[NotEmptyStr, int], + contributors: List[Union[int, str]], + categories: Union[List[str], Literal["*"]], + ): + """ + Remove one or more categories for a contributor. "*" in the category list will match all categories defined in the project. + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param contributors: A list of emails or IDs of the contributor. + :type contributors: List[Union[int, str]] + + :param categories: A list of category names to remove. Accepts "*" to indicate all available categories in the project. + :type categories: Union[List[str], Literal["*"]] + + Request Example: + :: + + client.remove_contributor_categories( + project="product-review-mm", + contributors=["test@superannotate.com","contributor@superannotate.com"], + categories=["Shoes", "T-Shirt", "Jeans"] + ) + + client.remove_contributor_categories( + project="product-review-mm", + contributors=["test@superannotate.com","contributor@superannotate.com"] + categories="*" + ) + """ + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.check_multimodal_project_categorization(project) + + self.controller.work_management.set_remove_contributor_categories( + project=project, + contributors=contributors, + categories=categories, + operation="remove", + ) + def get_component_config(self, project: Union[NotEmptyStr, int], component_id: str): """ Retrieves the configuration for a given project and component ID. @@ -1320,6 +1420,7 @@ def remove_categories( ) self.controller.check_multimodal_project_categorization(project) + categories_to_remove = None query = EmptyQuery() if categories == "*": query &= Filter("id", [0], OperatorEnum.GT) @@ -1335,14 +1436,13 @@ def remove_categories( else: raise AppException("Categories should be a list of strings or '*'.") - response = ( - self.controller.service_provider.work_management.remove_project_categories( + if categories_to_remove: + response = self.controller.service_provider.work_management.remove_project_categories( project_id=project.id, query=query ) - ) - logger.info( - f"{len(response.data)} categories successfully removed from the project." - ) + logger.info( + f"{len(response.data)} categories successfully removed from the project." + ) def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr): """ diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 8e23f38b9..57c880b63 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -129,11 +129,12 @@ def json(self, **kwargs): class WMProjectUserEntity(TimedBaseModel): id: Optional[int] team_id: Optional[int] - role: int + role: Optional[int] email: Optional[str] state: Optional[WMUserStateEnum] custom_fields: Optional[dict] = Field(dict(), alias="customField") permissions: Optional[dict] + categories: Optional[list[dict]] class Config: extra = Extra.ignore diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 6672383bc..49d7ffc9a 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -228,6 +228,17 @@ def create_score( def delete_score(self, score_id: int) -> ServiceResponse: raise NotImplementedError + @abstractmethod + def set_remove_contributor_categories( + self, + project_id: int, + contributor_ids: List[int], + category_ids: List[int], + operation: Literal["set", "remove"], + chunk_size=100, + ) -> list[dict]: + raise NotImplementedError + class BaseProjectService(SuperannotateServiceProvider): @abstractmethod diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index dd531a324..967d4f2cf 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -68,6 +68,9 @@ from typing_extensions import Unpack +logger = logging.getLogger("sa") + + def build_condition(**kwargs) -> Condition: condition = Condition.get_empty_condition() if any(kwargs.values()): @@ -177,7 +180,10 @@ def set_custom_field_value( ) def list_users( - self, include: List[Literal["custom_fields"]] = None, project=None, **filters + self, + include: List[Literal["custom_fields", "categories"]] = None, + project=None, + **filters, ): context = {"team_id": self.service_provider.client.team_id} if project: @@ -205,6 +211,10 @@ def list_users( ] ) query = chain.handle(filters, EmptyQuery()) + + if project and include and "categories" in include: + query &= Join("categories") + if include and "custom_fields" in include: response = self.service_provider.work_management.list_users( query, @@ -401,6 +411,76 @@ def set_user_scores( res.res_error = "Please provide valid score values." res.raise_for_status() + def set_remove_contributor_categories( + self, + project: ProjectEntity, + contributors: List[Union[int, str]], + categories: Union[List[str], Literal["*"]], + operation: Literal["set", "remove"], + ): + if categories and contributors: + all_categories = ( + self.service_provider.work_management.list_project_categories( + project_id=project.id, entity=ProjectCategoryEntity # noqa + ).data + ) + if categories == "*": + category_ids = [c.id for c in all_categories] + else: + categories = [c.lower() for c in categories] + category_ids = [ + c.id for c in all_categories if c.name.lower() in categories + ] + + if isinstance(contributors[0], str): + project_contributors = self.list_users( + project=project, email__in=contributors + ) + elif isinstance(contributors[0], int): + project_contributors = self.list_users( + project=project, id__in=contributors + ) + else: + raise AppException("Contributors not found.") + + if len(project_contributors) < len(contributors): + raise AppException("Contributors not found.") + + contributor_ids = [ + c.id + for c in project_contributors + if c.role != 3 # exclude Project Admins + ] + + if category_ids and contributor_ids: + response = self.service_provider.work_management.set_remove_contributor_categories( + project_id=project.id, + contributor_ids=contributor_ids, + category_ids=category_ids, + operation=operation, + ) + + success_processed = 0 + for contributor in response: + contributor_category_ids = [ + category["id"] for category in contributor["categories"] + ] + if operation == "set": + if set(category_ids).issubset(contributor_category_ids): + success_processed += len(category_ids) + else: + if not set(category_ids).intersection(contributor_category_ids): + success_processed += len(category_ids) + + if success_processed / len(contributor_ids) == len(category_ids): + action_for_log = ( + "added to" if operation == "set" else "removed from" + ) + logger.info( + f"{len(category_ids)} categories successfully {action_for_log} " + f"{len(contributor_ids)} contributors." + ) + class ProjectManager(BaseManager): def __init__(self, service_provider: ServiceProvider, team: TeamEntity): diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 2edf6fcec..861af83fa 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -14,6 +14,7 @@ from lib.core.entities.work_managament import WMUserEntity from lib.core.enums import CustomFieldEntityEnum from lib.core.exceptions import AppException +from lib.core.jsx_conditions import EmptyQuery from lib.core.jsx_conditions import Filter from lib.core.jsx_conditions import OperatorEnum from lib.core.jsx_conditions import Query @@ -71,6 +72,7 @@ class WorkManagementService(BaseWorkManagementService): URL_SEARCH_PROJECT_USERS = "projectusers/search" URL_SEARCH_PROJECTS = "projects/search" URL_RESUME_PAUSE_USER = "teams/editprojectsusers" + URL_CONTRIBUTORS_CATEGORIES = "customentities/edit" @staticmethod def _generate_context(**kwargs): @@ -475,3 +477,46 @@ def delete_score(self, score_id: int) -> ServiceResponse: ), }, ) + + def set_remove_contributor_categories( + self, + project_id: int, + contributor_ids: List[int], + category_ids: List[int], + operation: Literal["set", "remove"], + chunk_size=100, + ) -> List[dict]: + params = { + "entity": "Contributor", + "parentEntity": "Project", + } + if operation == "set": + params["action"] = "addcontributorcategory" + else: + params["action"] = "removecontributorcategory" + + from lib.infrastructure.utils import divide_to_chunks + + success_contributors = [] + + for chunk in divide_to_chunks(contributor_ids, chunk_size): + body_query = EmptyQuery() + body_query &= Filter("id", chunk, OperatorEnum.IN) + response = self.client.request( + url=self.URL_CONTRIBUTORS_CATEGORIES, + method="post", + params=params, + data={ + "query": body_query.body_builder(), + "body": {"categories": [{"id": i} for i in category_ids]}, + }, + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id, project_id=project_id + ), + }, + ) + response.raise_for_status() + success_contributors.extend(response.data["data"]) + + return success_contributors diff --git a/tests/integration/work_management/test_contributors_categories.py b/tests/integration/work_management/test_contributors_categories.py new file mode 100644 index 000000000..46c2f55f8 --- /dev/null +++ b/tests/integration/work_management/test_contributors_categories.py @@ -0,0 +1,266 @@ +import json +import os +import time +from pathlib import Path +from unittest import TestCase + +from lib.core.exceptions import AppException +from src.superannotate import SAClient + +sa = SAClient() + + +class TestContributorsCategories(TestCase): + PROJECT_NAME = "TestContributorsCategories" + PROJECT_TYPE = "Multimodal" + PROJECT_DESCRIPTION = "DESCRIPTION" + EDITOR_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1.json", + ) + CLASSES_TEMPLATE_PATH = os.path.join( + Path(__file__).parent.parent.parent, + "data_set/editor_templates/form1_classes.json", + ) + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + cls.tearDownClass() + cls._project = sa.create_project( + cls.PROJECT_NAME, + cls.PROJECT_DESCRIPTION, + cls.PROJECT_TYPE, + settings=[ + {"attribute": "TemplateState", "value": 1}, + {"attribute": "CategorizeItems", "value": 1}, + ], + ) + team = sa.controller.team + project = sa.controller.get_project(cls.PROJECT_NAME) + time.sleep(2) + + with open(cls.EDITOR_TEMPLATE_PATH) as f: + template_data = json.load(f) + res = sa.controller.service_provider.projects.attach_editor_template( + team, project, template=template_data + ) + assert res.ok + sa.create_annotation_classes_from_classes_json( + cls.PROJECT_NAME, cls.CLASSES_TEMPLATE_PATH + ) + users = sa.list_users() + scapegoat = [ + u for u in users if u["role"] == "Contributor" and u["state"] == "Confirmed" + ][0] + cls.scapegoat = scapegoat + sa.add_contributors_to_project( + cls.PROJECT_NAME, [scapegoat["email"]], "Annotator" + ) + + @classmethod + def tearDownClass(cls) -> None: + projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + for project in projects: + try: + sa.delete_project(project) + except Exception: + pass + + def tearDown(self): + # cleanup categories + sa.remove_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories="*", + ) + sa.remove_categories(project=self.PROJECT_NAME, categories="*") + + def test_set_contributors_categories(self): + test_categories = ["Category_A", "Category_B", "Category_C"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + categories = sa.list_categories(project=self.PROJECT_NAME) + assert len(categories) == len(test_categories) + + with self.assertLogs("sa", level="INFO") as cm: + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories=["Category_A", "Category_B"], + ) + assert ( + "INFO:sa:2 categories successfully added to 1 contributors." + == cm.output[0] + ) + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 2 + assert project_users[0]["categories"][0]["name"] == "Category_A" + assert project_users[0]["categories"][1]["name"] == "Category_B" + + def test_set_contributors_categories_all(self): + test_categories = ["Category_A", "Category_B", "Category_C", "Category_D"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + categories = sa.list_categories(project=self.PROJECT_NAME) + assert len(categories) == len(test_categories) + + with self.assertLogs("sa", level="INFO") as cm: + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories="*", + ) + assert ( + "INFO:sa:4 categories successfully added to 1 contributors." + == cm.output[0] + ) + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 4 + assert project_users[0]["categories"][0]["name"] == "Category_A" + assert project_users[0]["categories"][1]["name"] == "Category_B" + assert project_users[0]["categories"][2]["name"] == "Category_C" + assert project_users[0]["categories"][3]["name"] == "Category_D" + + def test_set_contributors_categories_by_id(self): + # Test assigning categories using contributor ID + test_categories = ["ID_Cat_A", "ID_Cat_B"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["id"]], + categories=test_categories, + ) + + # Verify categories were assigned + project_users = sa.list_users( + project=self.PROJECT_NAME, id=self.scapegoat["id"], include=["categories"] + ) + assigned_categories = [cat["name"] for cat in project_users[0]["categories"]] + for category in test_categories: + assert category in assigned_categories + + def test_set_contributors_categories_nonexistent(self): + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories=["NonExistentCategory"], + ) + + def test_remove_contributors_categories(self): + test_categories = ["RemoveCat_A", "RemoveCat_B", "RemoveCat_C"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories=test_categories, + ) + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 3 + + with self.assertLogs("sa", level="INFO") as cm: + sa.remove_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories=["RemoveCat_A", "RemoveCat_B"], + ) + assert "INFO:sa:2 categories successfully removed" in cm.output[0] + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 1 + assert project_users[0]["categories"][0]["name"] == "RemoveCat_C" + + def test_remove_all_contributors_categories(self): + test_categories = ["AllRemove_X", "AllRemove_Y", "AllRemove_Z"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + # First assign all categories + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories=test_categories, + ) + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 3 + + with self.assertLogs("sa", level="INFO") as cm: + sa.remove_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories="*", + ) + assert "INFO:sa:3 categories successfully removed" in cm.output[0] + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 0 + + def test_set_categories_with_invalid_contributor(self): + test_categories = ["Category_A", "Category_B", "Category_C"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + with self.assertRaisesRegexp(AppException, "Contributors not found.") as cm: + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"], "invalid_email@mail.com"], + categories=["Category_A", "Category_B"], + ) + + def test_set_contributors_with_invalid_categories(self): + test_categories = ["Category_A", "Category_B", "Category_C"] + sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + + sa.set_contributors_categories( + project=self.PROJECT_NAME, + contributors=[self.scapegoat["email"]], + categories=[ + "Category_A", + "Category_C", + "InvalidCategory_1", + "InvalidCategory_2", + ], + ) + + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["categories"], + ) + assert len(project_users) == 1 + assert len(project_users[0]["categories"]) == 2 + assert project_users[0]["categories"][0]["name"] == "Category_A" + assert project_users[0]["categories"][1]["name"] == "Category_C" From c4df9be150351bfad5b24000354a3b71aec042d3 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 10 Jul 2025 12:17:30 +0400 Subject: [PATCH 04/12] fixed list_users --- .../lib/app/interface/sdk_interface.py | 2 +- .../work_management/test_list_users.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/integration/work_management/test_list_users.py diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index f6beab25d..bc6b820ef 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -659,7 +659,7 @@ def list_users( """ if project is not None: if isinstance(project, int): - project = self.controller.get_project_by_id(project) + project = self.controller.get_project_by_id(project).data else: project = self.controller.get_project(project) response = BaseSerializer.serialize_iterable( diff --git a/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py new file mode 100644 index 000000000..53c7d3213 --- /dev/null +++ b/tests/integration/work_management/test_list_users.py @@ -0,0 +1,38 @@ +from superannotate import SAClient +from tests.integration.base import BaseTestCase + +sa = SAClient() + + +class TestListUsers(BaseTestCase): + PROJECT_NAME = "TestListUsers" + PROJECT_TYPE = "Vector" + + def setUp(self): + super().setUp() + team_users = sa.list_users() + assert len(team_users) > 0 + scapegoat = [ + u + for u in team_users + if u["role"] == "Contributor" and u["state"] == "Confirmed" + ][0] + self.scapegoat = scapegoat + sa.add_contributors_to_project( + self.PROJECT_NAME, [scapegoat["email"]], "Annotator" + ) + + def test_list_users_by_project_name(self): + project_users = sa.list_users(project=self.PROJECT_NAME) + assert len(project_users) == 1 + user_1 = project_users[0] + assert user_1["role"] == "Annotator" + assert user_1["email"] == self.scapegoat["email"] + + def test_list_users_by_project_ID(self): + project = sa.get_project_metadata(self.PROJECT_NAME) + project_users = sa.list_users(project=project["id"]) + assert len(project_users) == 1 + user_1 = project_users[0] + assert user_1["role"] == "Annotator" + assert user_1["email"] == self.scapegoat["email"] From e96f058768bdda80e3ebba74e9b7d988e3cdca26 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 10 Jul 2025 12:52:44 +0400 Subject: [PATCH 05/12] add categories arg validation --- .../lib/app/interface/sdk_interface.py | 37 +++++++++++++------ .../test_project_categories.py | 20 ++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index f6beab25d..17c389921 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -867,7 +867,7 @@ def set_contributors_categories( self, project: Union[NotEmptyStr, int], contributors: List[Union[int, str]], - categories: Union[List[str], Literal["*"]], + categories: Union[List[NotEmptyStr], Literal["*"]], ): """ Assign one or more categories to a contributor with an assignable role (Annotator, QA or custom role) @@ -899,6 +899,9 @@ def set_contributors_categories( categories="*" ) """ + if not categories: + AppException("Categories should be a list of strings or '*'.") + project = ( self.controller.get_project_by_id(project).data if isinstance(project, int) @@ -917,7 +920,7 @@ def remove_contributors_categories( self, project: Union[NotEmptyStr, int], contributors: List[Union[int, str]], - categories: Union[List[str], Literal["*"]], + categories: Union[List[NotEmptyStr], Literal["*"]], ): """ Remove one or more categories for a contributor. "*" in the category list will match all categories defined in the project. @@ -946,6 +949,9 @@ def remove_contributors_categories( categories="*" ) """ + if not categories: + AppException("Categories should be a list of strings or '*'.") + project = ( self.controller.get_project_by_id(project).data if isinstance(project, int) @@ -1297,7 +1303,7 @@ def clone_project( return data def create_categories( - self, project: Union[NotEmptyStr, int], categories: List[str] + self, project: Union[NotEmptyStr, int], categories: List[NotEmptyStr] ): """ Create one or more categories in a project. @@ -1316,6 +1322,9 @@ def create_categories( categories=["Shoes", "T-Shirt"] ) """ + if not categories: + raise AppException("Categories should be a list of strings.") + project = ( self.controller.get_project_by_id(project).data if isinstance(project, int) @@ -1387,7 +1396,7 @@ def list_categories(self, project: Union[NotEmptyStr, int]): def remove_categories( self, project: Union[NotEmptyStr, int], - categories: Union[List[str], Literal["*"]], + categories: Union[List[NotEmptyStr], Literal["*"]], ): """ Remove one or more categories in a project. "*" in the category list will match all categories defined in the project. @@ -1413,6 +1422,9 @@ def remove_categories( categories="*" ) """ + if not categories: + AppException("Categories should be a list of strings or '*'.") + project = ( self.controller.get_project_by_id(project).data if isinstance(project, int) @@ -1420,7 +1432,6 @@ def remove_categories( ) self.controller.check_multimodal_project_categorization(project) - categories_to_remove = None query = EmptyQuery() if categories == "*": query &= Filter("id", [0], OperatorEnum.GT) @@ -1432,17 +1443,21 @@ def remove_categories( categories_to_remove = [ c for c in all_categories.data if c.name.lower() in categories ] - query &= Filter("id", [c.id for c in categories_to_remove], OperatorEnum.IN) + if categories_to_remove: + query &= Filter( + "id", [c.id for c in categories_to_remove], OperatorEnum.IN + ) else: raise AppException("Categories should be a list of strings or '*'.") - if categories_to_remove: + if query.condition_set: response = self.controller.service_provider.work_management.remove_project_categories( project_id=project.id, query=query ) - logger.info( - f"{len(response.data)} categories successfully removed from the project." - ) + if response.data: + logger.info( + f"{len(response.data)} categories successfully removed from the project." + ) def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr): """ @@ -4497,7 +4512,7 @@ def set_items_category( self, project: Union[NotEmptyStr, Tuple[int, int], Tuple[str, str]], items: List[Union[int, str]], - category: str, + category: NotEmptyStr, ): """ Add categories to one or more items. diff --git a/tests/integration/work_management/test_project_categories.py b/tests/integration/work_management/test_project_categories.py index fbb55ec34..2a63d4c7e 100644 --- a/tests/integration/work_management/test_project_categories.py +++ b/tests/integration/work_management/test_project_categories.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest import TestCase +from lib.core.exceptions import AppException from src.superannotate import SAClient sa = SAClient() @@ -144,3 +145,22 @@ def test_delete_all_categories_with_asterisk(self): sa.remove_categories(project=self.PROJECT_NAME, categories="*") categories = sa.list_categories(project=self.PROJECT_NAME) assert len(categories) == 0 + + def test_delete_categories_with_empty_list(self): + with self.assertRaisesRegexp( + AppException, "Categories should be a list of strings or '*'" + ): + sa.remove_categories(project=self.PROJECT_NAME, categories=[]) + + def test_delete_invalid_categories(self): + # silent skip + sa.remove_categories( + project=self.PROJECT_NAME, + categories=["invalid_category_1", "invalid_category_2"], + ) + + def test_create_categories_with_empty_categories(self): + with self.assertRaisesRegexp( + AppException, "Categories should be a list of strings." + ): + sa.create_categories(project=self.PROJECT_NAME, categories=[]) From 923d9f1e4b179d6aaa72d11b3cf9052e22480df8 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Fri, 11 Jul 2025 14:41:59 +0400 Subject: [PATCH 06/12] fix in tests --- .../work_management/test_contributors_categories.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/integration/work_management/test_contributors_categories.py b/tests/integration/work_management/test_contributors_categories.py index 46c2f55f8..426cf1fff 100644 --- a/tests/integration/work_management/test_contributors_categories.py +++ b/tests/integration/work_management/test_contributors_categories.py @@ -32,7 +32,7 @@ def setUpClass(cls, *args, **kwargs) -> None: cls.PROJECT_TYPE, settings=[ {"attribute": "TemplateState", "value": 1}, - {"attribute": "CategorizeItems", "value": 1}, + {"attribute": "CategorizeItems", "value": 2}, ], ) team = sa.controller.team @@ -138,15 +138,20 @@ def test_set_contributors_categories_by_id(self): test_categories = ["ID_Cat_A", "ID_Cat_B"] sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) + scapegoat_project_id = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + )[0]["id"] + sa.set_contributors_categories( project=self.PROJECT_NAME, - contributors=[self.scapegoat["id"]], + contributors=[scapegoat_project_id], categories=test_categories, ) # Verify categories were assigned project_users = sa.list_users( - project=self.PROJECT_NAME, id=self.scapegoat["id"], include=["categories"] + project=self.PROJECT_NAME, id=scapegoat_project_id, include=["categories"] ) assigned_categories = [cat["name"] for cat in project_users[0]["categories"]] for category in test_categories: From e9aba375254f0c1641424fb7494a3d253a2ba5de Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 15 Jul 2025 19:26:35 +0400 Subject: [PATCH 07/12] fix in set_remove_contributor_categories --- .../lib/infrastructure/services/work_management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 861af83fa..fdb182dd8 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -507,7 +507,7 @@ def set_remove_contributor_categories( method="post", params=params, data={ - "query": body_query.body_builder(), + **body_query.body_builder(), "body": {"categories": [{"id": i} for i in category_ids]}, }, headers={ From 64225a607ab495afbf47f6cc0cef901a46295fd2 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 16 Jul 2025 17:59:07 +0400 Subject: [PATCH 08/12] version update --- requirements.txt | 2 +- src/superannotate/__init__.py | 2 +- src/superannotate/lib/infrastructure/services/http_client.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index a289dc6a6..03143bcb7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ plotly~=5.14 pandas~=2.0 ffmpeg-python~=0.2 pillow>=9.5,~=10.0 -tqdm~=4.66.1 +tqdm~=4.66 requests==2.* aiofiles==23.* fire==0.4.0 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 6beddbe4f..2390b15c4 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.36" +__version__ = "4.4.37" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index 71971a207..4f627038e 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -295,7 +295,7 @@ def serialize_response( else: data["res_data"] = data_json return content_type(**data) - except json.decoder.JSONDecodeError: + except ValueError: data["res_error"] = response.content data["reason"] = response.reason return content_type(**data) From 40fa77efcc22f8a4b21284f7d562f462cfe70d07 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 17 Jul 2025 11:14:03 +0400 Subject: [PATCH 09/12] fix in UploadMultiModalAnnotationsUseCase --- src/superannotate/lib/core/usecases/annotations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 0e5365e52..c091ffcff 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -2178,5 +2178,5 @@ def _attach_categories(self, folder_id: int, item_id_category_map: Dict[int, str self._service_provider.items.bulk_attach_categories( project_id=self._project.id, folder_id=folder_id, - item_category_map=item_id_category_id_map, + item_id_category_id_map=item_id_category_id_map, ) From 8da5c58ba79359d37114d24d6f6ef57a1017cefb Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 17 Jul 2025 13:05:49 +0400 Subject: [PATCH 10/12] fix in tests --- tests/integration/items/test_list_items.py | 8 ++++---- tests/integration/mixpanel/test_mixpanel_decorator.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index 625f1573b..c5ff8dfa2 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -72,11 +72,11 @@ class TestListItemsMultimodal(BaseTestCase): TEST_FOLDER_PATH = "data_set/sample_project_vector" CATEGORIES = ["c_1", "c_2", "c_3"] ANNOTATIONS = [ - {"metadata": {"name": "item_1", "item_category": {"value": "c1"}}, "data": {}}, - {"metadata": {"name": "item_2", "item_category": {"value": "c2"}}, "data": {}}, - {"metadata": {"name": "item_3", "item_category": {"value": "c3"}}, "data": {}}, + {"metadata": {"name": "item_1", "item_category": "c1"}, "data": {}}, + {"metadata": {"name": "item_2", "item_category": "c2"}, "data": {}}, + {"metadata": {"name": "item_3", "item_category": "c3"}, "data": {}}, ] - CLASSES_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/from1_classes.json" + CLASSES_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/form1_classes.json" EDITOR_TEMPLATE_PATH = DATA_SET_PATH / "editor_templates/form1.json" def setUp(self, *args, **kwargs): diff --git a/tests/integration/mixpanel/test_mixpanel_decorator.py b/tests/integration/mixpanel/test_mixpanel_decorator.py index 399c55b25..6991dceed 100644 --- a/tests/integration/mixpanel/test_mixpanel_decorator.py +++ b/tests/integration/mixpanel/test_mixpanel_decorator.py @@ -113,7 +113,7 @@ def test_get_team_metadata(self, track_method): sa.get_team_metadata() team_owner = sa.controller.current_user.email result = list(track_method.call_args)[0] - payload = self.default_payload + payload = {**self.default_payload, "include": None} assert result[0] == team_owner assert result[1] == "get_team_metadata" assert payload == result[2] From 110ee193f16b682d4f299a9f61452ddb21074372 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:09:03 +0400 Subject: [PATCH 11/12] Update CHANGELOG.rst --- CHANGELOG.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c1406cbdd..0b11a7e3c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,24 @@ History All release highlights of this project will be documented in this file. +4.4.37 - July 18, 2025 +______________________ + +**Added** + + - ``SAClient.create_categories`` creates one or more categories in a project. + - ``SAClient.remove_categories`` removes one or more categories in a project. + - ``SAClient.list_categories`` lists all categories in the project. + - ``SAClient.set_contributors_categories`` assigns one or more categories to specified contributors. + - ``SAClient.remove_contributors_categories`` removes specified categories from contributors. + - ``SAClient.set_items_category`` adds categories to one or more items. + - ``SAClient.remove_items_category`` removes categories from one or more items. + +**Updated** + + - ``SAClient.list_users`` now includes an optional ``categories`` value in the ``include`` parameter to return each contributor's assigned categories when a project is specified. + + 4.4.36 - June 05, 2025 ______________________ From 0eed125140e5c4c2dccfde9811b93645b897668c Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 18 Jul 2025 11:07:58 +0400 Subject: [PATCH 12/12] Update data typing --- CHANGELOG.rst | 4 ++-- src/superannotate/lib/core/entities/work_managament.py | 3 ++- src/superannotate/lib/core/serviceproviders.py | 2 +- tests/integration/items/test_attach_category.py | 3 ++- tests/integration/items/test_list_items.py | 4 +++- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0b11a7e3c..7bfe5e401 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,12 +11,12 @@ ______________________ **Added** - - ``SAClient.create_categories`` creates one or more categories in a project. + - ``SAClient.create_categories`` creates one or more categories in a project. - ``SAClient.remove_categories`` removes one or more categories in a project. - ``SAClient.list_categories`` lists all categories in the project. - ``SAClient.set_contributors_categories`` assigns one or more categories to specified contributors. - ``SAClient.remove_contributors_categories`` removes specified categories from contributors. - - ``SAClient.set_items_category`` adds categories to one or more items. + - ``SAClient.set_items_category`` adds categories to one or more items. - ``SAClient.remove_items_category`` removes categories from one or more items. **Updated** diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 57c880b63..75ea6d088 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -2,6 +2,7 @@ from enum import auto from enum import Enum from typing import Any +from typing import List from typing import Optional from typing import Union @@ -134,7 +135,7 @@ class WMProjectUserEntity(TimedBaseModel): state: Optional[WMUserStateEnum] custom_fields: Optional[dict] = Field(dict(), alias="customField") permissions: Optional[dict] - categories: Optional[list[dict]] + categories: Optional[List[dict]] class Config: extra = Extra.ignore diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 49d7ffc9a..a2fcdd175 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -236,7 +236,7 @@ def set_remove_contributor_categories( category_ids: List[int], operation: Literal["set", "remove"], chunk_size=100, - ) -> list[dict]: + ) -> List[dict]: raise NotImplementedError diff --git a/tests/integration/items/test_attach_category.py b/tests/integration/items/test_attach_category.py index eb812db38..2b23fee92 100644 --- a/tests/integration/items/test_attach_category.py +++ b/tests/integration/items/test_attach_category.py @@ -2,6 +2,7 @@ import os import time from pathlib import Path +from typing import List from unittest import TestCase from src.superannotate import SAClient @@ -59,7 +60,7 @@ def tearDownClass(cls) -> None: pass @staticmethod - def _attach_items(path: str, names: list[str]): + def _attach_items(path: str, names: List[str]): sa.attach_items(path, [{"name": name, "url": f"url-{name}"} for name in names]) def test_attache_category(self): diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index c5ff8dfa2..bd490bd3e 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -109,7 +109,9 @@ def test_list_category_filter(self): include=["categories"], categories__value__in=["c1", "c2"], ) - assert [i["categories"][0]["value"] for i in items] == ["c1", "c2"] + assert sorted([i["categories"][0]["value"] for i in items]) == sorted( + ["c1", "c2"] + ) assert ( len( sa.list_items(