diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a10adf36d..463755987 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: black name: Code Formatter (black) - repo: 'https://github.com/PyCQA/flake8' - rev: 3.8.2 + rev: 6.1.0 hooks: - id: flake8 exclude: src/lib/app/analytics | src/lib/app/converters | src/lib/app/input_converters diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 328a336c2..91b0d5763 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,18 @@ History All release highlights of this project will be documented in this file. +4.4.39 - November 13, 2025 +________________________ + +**Updated** + + - ``SAClient.get_item_by_id`` now supports an optional include parameter to fetch additional fields like custom_metadata and categories. + +**Updated** + + - ``SAClient.assign_items`` now supports assigning items and folders to pending users. + - ``SAClient.assign_folder`` now supports assigning items and folders to pending users. + 4.4.38 - August 20, 2025 ________________________ diff --git a/requirements.txt b/requirements.txt index f5c9c033d..ee471b63b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ pydantic>=1.10,!=2.0.* aiohttp~=3.8 boto3~=1.26 opencv-python-headless~=4.7 -packaging~=24.0 plotly~=5.14 pandas~=2.0 pillow>=9.5,~=10.0 diff --git a/requirements_extra.txt b/requirements_extra.txt index 7baa70b02..cb8054ceb 100644 --- a/requirements_extra.txt +++ b/requirements_extra.txt @@ -1,13 +1,13 @@ setuptools~=67.7 wheel~=0.40 -Sphinx==6.2.1 -tox==4.5.1 +Sphinx~=6.2.1 +tox~=4.0 sphinx_rtd_theme==1.2.0 furo==2023.3.27 jaraco.tidelift==1.5.1 sphinx-notfound-page==0.8.3 sphinx_inline_tabs==2023.4.21 -pytest==7.3.1 +pytest~=8.0 pytest-xdist==3.2.1 pytest-parallel==0.1.1 pytest-cov==4.0.0 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 2959deb09..2605a8d06 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -3,7 +3,7 @@ import sys -__version__ = "4.4.38" +__version__ = "4.4.39" os.environ.update({"sa_version": __version__}) @@ -11,7 +11,7 @@ import requests from lib.core import enums -from packaging.version import parse +from lib.core.utils import parse_version from lib.core import PACKAGE_VERSION_UPGRADE from lib.core import PACKAGE_VERSION_INFO_MESSAGE from lib.core import PACKAGE_VERSION_MAJOR_UPGRADE @@ -47,15 +47,15 @@ def log_version_info(): logging.StreamHandler(sys.stdout) - local_version = parse(__version__) + local_version = parse_version(__version__) if local_version.is_prerelease: logging.info(PACKAGE_VERSION_INFO_MESSAGE.format(__version__)) req = requests.get("https://pypi.org/pypi/superannotate/json") if req.ok: releases = req.json().get("releases", []) - pip_version = parse("0") + pip_version = parse_version("0") for release in releases: - ver = parse(release) + ver = parse_version(release) if not ver.is_prerelease or local_version.is_prerelease: pip_version = max(pip_version, ver) if pip_version.major > local_version.major: diff --git a/src/superannotate/lib/app/helpers.py b/src/superannotate/lib/app/helpers.py index 78244d916..990405aa8 100644 --- a/src/superannotate/lib/app/helpers.py +++ b/src/superannotate/lib/app/helpers.py @@ -123,5 +123,5 @@ def wrap_error(errors_list: List[Tuple[str, str]]) -> str: _tabulation = tabulation - len(key) if not key: key, value, _tabulation = value, "", 0 - msgs.append(f'{key}{ " " * _tabulation}{value}') + msgs.append(f'{key}{" " * _tabulation}{value}') return "\n".join(msgs) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index c3512dff1..806d89465 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -335,7 +335,12 @@ def get_folder_by_id(self, project_id: int, folder_id: int): exclude={"completedCount", "is_root"} ) - def get_item_by_id(self, project_id: int, item_id: int): + def get_item_by_id( + self, + project_id: int, + item_id: int, + include: List[Literal["custom_metadata", "categories"]] = None, + ): """Returns the item metadata :param project_id: the id of the project @@ -344,16 +349,50 @@ def get_item_by_id(self, project_id: int, item_id: int): :param item_id: the id of the item :type item_id: int + :param include: Specifies additional fields to include in the response. + + Possible values are + + - "custom_metadata": Includes custom metadata attached to the item. + - "categories": Includes categories attached to the item. + :type include: list of str, optional + :return: item metadata :rtype: dict """ project_response = self.controller.get_project_by_id(project_id=project_id) project_response.raise_for_status() - item = self.controller.get_item_by_id( - item_id=item_id, project=project_response.data + + if ( + include + and "categories" in include + and project_response.data.type != ProjectType.MULTIMODAL.value + ): + raise AppException( + "The 'categories' option in the 'include' field is only supported for Multimodal projects." + ) + + # always join assignments for all project types + _include = {"assignments"} + if include: + _include.update(set(include)) + include = list(_include) + + include_custom_metadata = "custom_metadata" in include + if include_custom_metadata: + include.remove("custom_metadata") + + item = self.controller.items.get_item_by_id( + item_id=item_id, project=project_response.data, include=include ) - return BaseSerializer(item).serialize(exclude={"url", "meta"}) + if include_custom_metadata: + item_custom_fields = self.controller.custom_fields.list_fields( + project=project_response.data, item_ids=[item.id] + ) + item.custom_metadata = item_custom_fields[item.id] + + return BaseSerializer(item).serialize(exclude={"url", "meta"}, by_alias=False) def get_team_metadata(self, include: List[Literal["scores"]] = None): """ @@ -2102,15 +2141,10 @@ def assign_folder( if response.errors: raise AppException(response.errors) project = response.data - response = self.controller.projects.get_metadata( - project=project, include_contributors=True + project_contributors = self.controller.work_management.list_users( + project=project ) - - if response.errors: - raise AppException(response.errors) - - contributors = response.data.users - verified_users = [i.user_id for i in contributors] + verified_users = [i.email for i in project_contributors] verified_users = set(users).intersection(set(verified_users)) unverified_contributor = set(users) - verified_users @@ -4161,7 +4195,7 @@ def list_projects( :param filters: Specifies filtering criteria, with all conditions combined using logical AND. - - Only users matching all filter conditions are returned. + - Only projects matching all filter conditions are returned. - If no filter operation is provided, an exact match is applied. @@ -4195,7 +4229,7 @@ def list_projects( Custom Fields Filtering: - Custom fields must be prefixed with `custom_field__`. - - Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due_date is after the given Unix timestamp). + - Example: custom_field__Due_date__gte="1738281600" (filtering projects whose Due_date is after the given Unix timestamp). - If include does not include “custom_fields” but filter contains ‘custom_field’, an error will be returned - **Text** custom field only works with the following filter params: __in, __notin, __contains @@ -5237,7 +5271,7 @@ def item_context( "This function is only supported for Multimodal projects." ) if isinstance(item, int): - _item = self.controller.get_item_by_id(item_id=item, project=project) + _item = self.controller.items.get_item_by_id(item_id=item, project=project) else: items = self.controller.items.list_items(project, folder, name=item) if not items: diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index a27bf5ad4..9a8da45b0 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -1,5 +1,6 @@ import logging import os +import sys from logging import Formatter from logging.handlers import RotatingFileHandler from os.path import expanduser @@ -36,10 +37,21 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): logger.removeHandler(handler) logger.propagate = False logger.setLevel(level) - stream_handler = logging.StreamHandler() + + # Separate handlers for different log levels + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setLevel(logging.DEBUG) + stdout_handler.addFilter(lambda record: record.levelno < logging.WARNING) + + stderr_handler = logging.StreamHandler(sys.stderr) + stderr_handler.setLevel(logging.WARNING) + formatter = Formatter("SA-PYTHON-SDK - %(levelname)s - %(message)s") - stream_handler.setFormatter(formatter) - logger.addHandler(stream_handler) + stdout_handler.setFormatter(formatter) + stderr_handler.setFormatter(formatter) + + logger.addHandler(stdout_handler) + logger.addHandler(stderr_handler) try: os.makedirs(file_path, exist_ok=True) log_file_path = os.path.join(file_path, "sa.log") diff --git a/src/superannotate/lib/core/pydantic_v1.py b/src/superannotate/lib/core/pydantic_v1.py index b1b892870..df3b621c6 100644 --- a/src/superannotate/lib/core/pydantic_v1.py +++ b/src/superannotate/lib/core/pydantic_v1.py @@ -1,6 +1,7 @@ -from packaging.version import parse as parse_version +from lib.core.utils import parse_version from pydantic import VERSION + if parse_version(VERSION).major < 2: import pydantic else: diff --git a/src/superannotate/lib/core/utils.py b/src/superannotate/lib/core/utils.py index b01e3b86c..9c53f1b15 100644 --- a/src/superannotate/lib/core/utils.py +++ b/src/superannotate/lib/core/utils.py @@ -1,4 +1,5 @@ import asyncio +import re import typing from threading import Thread @@ -46,3 +47,58 @@ def wrapper(func: typing.Callable): thread.start() thread.join() return response[0] + + +def parse_version(version_string): + """Smart version parsing with support for various formats""" + # Remove 'v' prefix if present + version_string = version_string.lstrip("v") + + # Extract version parts using regex + match = re.match( + r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-.]?(a|b|rc|dev|alpha|beta)(\d*))?", + version_string, + ) + + if not match: + raise ValueError(f"Invalid version format: {version_string}") + + major = int(match.group(1)) + minor = int(match.group(2) or 0) + patch = int(match.group(3) or 0) + pre_type = match.group(4) + pre_num = int(match.group(5) or 0) if match.group(5) else 0 + + class Version: + def __init__(self, major, minor, patch, pre_type=None, pre_num=0): + self.major = major + self.minor = minor + self.patch = patch + self.pre_type = pre_type + self.pre_num = pre_num + + @property + def is_prerelease(self): + return self.pre_type is not None + + def __str__(self): + version = f"{self.major}.{self.minor}.{self.patch}" + if self.pre_type: + version += f"-{self.pre_type}{self.pre_num}" + return version + + def __gt__(self, other): + if self.major != other.major: + return self.major > other.major + if self.minor != other.minor: + return self.minor > other.minor + if self.patch != other.patch: + return self.patch > other.patch + # Handle prerelease comparison + if self.is_prerelease and not other.is_prerelease: + return False + if not self.is_prerelease and other.is_prerelease: + return True + return self.pre_num > other.pre_num + + return Version(major, minor, patch, pre_type, pre_num) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index ed0b18a3a..74aca75da 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -50,8 +50,6 @@ from lib.core.jsx_conditions import Query from lib.core.reporter import Reporter from lib.core.response import Response -from lib.core.service_types import PROJECT_TYPE_RESPONSE_MAP -from lib.core.usecases import serialize_item_entity from lib.infrastructure.custom_entities import generate_schema from lib.infrastructure.helpers import timed_lru_cache from lib.infrastructure.query_builder import FieldValidationHandler @@ -932,7 +930,7 @@ def process_response( service_provider, items: List[BaseItemEntity], project: ProjectEntity, - folder: FolderEntity, + folder: Optional[FolderEntity] = None, map_fields: bool = True, ) -> List[BaseItemEntity]: """Process the response data and return a list of serialized items.""" @@ -940,7 +938,8 @@ def process_response( for item in items: if map_fields: item = usecases.serialize_item_entity(item, project) - item = usecases.add_item_path(project, folder, item) + if folder: + item = usecases.add_item_path(project, folder, item) else: item = usecases.serialize_item_entity(item, project, map_fields=False) item.annotation_status = service_provider.get_annotation_status_name( @@ -985,6 +984,30 @@ def list_items( data = self.service_provider.item_service.list(project.id, folder.id, query) return self.process_response(self.service_provider, data, project, folder) + def get_item_by_id( + self, + item_id: int, + project: ProjectEntity, + include: List[Literal["categories", "assignments"]] = None, + ) -> BaseItemEntity: + query = EmptyQuery() + + if include: + if "assignments" in include: + query &= Join("assignments") + if "categories" in include: + # join item categories for multimodal projects + query &= Join("categories") + + response = self.service_provider.item_service.get( + project_id=project.id, item_id=item_id, query=query + ) + if response.error: + raise AppException(response.error) + + item = self.process_response(self.service_provider, [response.data], project)[0] + return item + def attach( self, project: ProjectEntity, @@ -1631,19 +1654,6 @@ def get_project_by_id(self, project_id: int): raise AppException("Project not found.") return response - def get_item_by_id(self, item_id: int, project: ProjectEntity) -> BaseItemEntity: - response = self.service_provider.item_service.get( - project_id=project.id, item_id=item_id - ) - if response.error: - raise AppException(response.error) - PROJECT_TYPE_RESPONSE_MAP[project.type] = response.data - item = serialize_item_entity(response.data, project) - item.annotation_status = self.service_provider.get_annotation_status_name( - project, item.annotation_status - ) - return item - def get_project_folder_by_path( self, path: Union[str, Path] ) -> Tuple[ProjectEntity, FolderEntity]: @@ -2044,7 +2054,7 @@ def get_item( self, project: ProjectEntity, folder: FolderEntity, item: Union[int, str] ) -> BaseItemEntity: if isinstance(item, int): - return self.get_item_by_id(item_id=item, project=project) + return self.items.get_item_by_id(item_id=item, project=project) else: return self.items.get_by_name(project, folder, item) diff --git a/src/superannotate/lib/infrastructure/services/item_service.py b/src/superannotate/lib/infrastructure/services/item_service.py index 5a4bbe238..bf1b5ed7b 100644 --- a/src/superannotate/lib/infrastructure/services/item_service.py +++ b/src/superannotate/lib/infrastructure/services/item_service.py @@ -18,9 +18,9 @@ class ItemService(SuperannotateServiceProvider): URL_LIST = "items" URL_GET = "items/{item_id}" - def get(self, project_id: int, item_id: int): + def get(self, project_id: int, item_id: int, query: Query): result = self.client.request( - url=self.URL_GET.format(item_id=item_id), + url=f"{self.URL_GET.format(item_id=item_id)}?{query.build_query()}", method="GET", content_type=BaseItemResponse, headers={ diff --git a/tests/integration/custom_fields/test_custom_schema.py b/tests/integration/custom_fields/test_custom_schema.py index d076a8a90..8bb93a06c 100644 --- a/tests/integration/custom_fields/test_custom_schema.py +++ b/tests/integration/custom_fields/test_custom_schema.py @@ -136,6 +136,11 @@ def test_get_item_metadata(self): item = sa.get_item_metadata( self.PROJECT_NAME, item_name, include_custom_metadata=True ) + # test custom_metadata using get_item_by_id + assert item["custom_metadata"] == payload + item = sa.get_item_by_id( + self._project["id"], item["id"], include=["custom_metadata"] + ) assert item["custom_metadata"] == payload def test_get_item_metadata_without_custom_metadata(self): diff --git a/tests/integration/folders/test_assign_folder.py b/tests/integration/folders/test_assign_folder.py new file mode 100644 index 000000000..cb82a58ec --- /dev/null +++ b/tests/integration/folders/test_assign_folder.py @@ -0,0 +1,48 @@ +from superannotate import SAClient +from tests.integration.base import BaseTestCase + +sa = SAClient() + + +class TestAssignFolderToUsers(BaseTestCase): + PROJECT_NAME = "TestAssignFolderToUsers" + PROJECT_TYPE = "Vector" + FOLDER_NAME = "test_folder_assign" + + def setUp(self): + super().setUp() + team_users = sa.list_users() + assert len(team_users) > 0 + self.scapegoat_accepted = next( + ( + u + for u in team_users + if u["state"] == "Confirmed" and u["role"] == "Contributor" + ), + None, + ) + self.scapegoat_pending = next( + ( + u + for u in team_users + if u["state"] == "Pending" and u["role"] == "Contributor" + ), + None, + ) + sa.add_contributors_to_project( + self.PROJECT_NAME, + [self.scapegoat_accepted["email"], self.scapegoat_pending["email"]], + "Annotator", + ) + project_users = sa.list_users(project=self.PROJECT_NAME) + assert len(project_users) == 2 + + def test_assign_folder_to_users(self): + # create folder + sa.create_folder(self.PROJECT_NAME, self.FOLDER_NAME) + + sa.assign_folder( + self.PROJECT_NAME, + self.FOLDER_NAME, + [self.scapegoat_accepted["email"], self.scapegoat_pending["email"]], + ) diff --git a/tests/integration/items/test_attach_category.py b/tests/integration/items/test_attach_category.py index 18195e2b7..c53cba815 100644 --- a/tests/integration/items/test_attach_category.py +++ b/tests/integration/items/test_attach_category.py @@ -92,6 +92,12 @@ def test_attache_category(self): items = sa.list_items(self.PROJECT_NAME, include=["categories"]) assert all(i["categories"][0]["value"] == "category-1" for i in items) + # test get categories to use get_item_by_id + item = sa.get_item_by_id( + self._project["id"], items[0]["id"], include=["categories"] + ) + assert item["categories"][0]["value"] == "category-1" + 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"]) diff --git a/tests/integration/work_management/test_pause_resume_user_activity.py b/tests/integration/work_management/test_pause_resume_user_activity.py index 50ae15919..6cd3e3f7f 100644 --- a/tests/integration/work_management/test_pause_resume_user_activity.py +++ b/tests/integration/work_management/test_pause_resume_user_activity.py @@ -67,6 +67,11 @@ def test_pause_and_resume_user_activity(self): [i["name"] for i in self.ATTACHMENT_LIST], scapegoat["email"], ) + # test assignments use get_item_by_id + items_id = [i["id"] for i in sa.list_items(self.PROJECT_NAME)][0] + item = sa.get_item_by_id(project_id=self._project["id"], item_id=items_id) + assert len(item["assignments"]) == 1 + assert item["assignments"][0]["user_role"] == "QA" @skip("For not send real email") def test_pause_resume_pending_user(self):