diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f762fbee4..0015d7c30 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: name: Style Guide Enforcement (flake8) args: - '--max-line-length=120' - - --ignore=D100,D203,D405,W503,E203,E501,F841,E126,E712,E123,E131,F821,E121,W605,E402 + - --ignore=D100,D203,D405,W503,E203,E501,F841,E126,E712,E123,E131,F821,E121,W605,E402,E704 - repo: 'https://github.com/asottile/pyupgrade' rev: v3.21.2 hooks: @@ -62,7 +62,9 @@ repos: # args: # - '--disable=R0903,C0111,C0301,W0703,R0914,R0801,R0913,E0401,W0511,C0413,R0902,C0103,W0201,C0209,W1203,W0707,C0415,W0611' # - repo: 'https://github.com/asottile/dead' -# rev: v1.3.0 +# rev: v2.1.0 # hooks: # - id: dead +# args: [--exclude, docs/source/conf.py|src/superannotate/lib/app/interface/sdk_interface.py|src/superannotate/lib/app/interface/cli_interface.py] + exclude: src/lib/app/analytics | src/lib/app/input_converters diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4bdff3f8b..0814a37fc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,21 @@ History All release highlights of this project will be documented in this file. +4.5.5 - May 31, 2026 +______________________ + +**Added** + + - ``SAClient.query.count()`` Returns the number of items that match a query. + - ``SAClient.grant_project_user_permissions()`` Grants project-level user permissions. + - ``SAClient.revoke_project_user_permissions()`` Revokes project-level user permissions. + +**Updated** + + - ``SAClient.list_users()`` Now returns user permissions. + - ``SAClient.create_project()`` Now supports the `MaxIdleDuration` setting. + + 4.5.4 - April 23, 2026 ______________________ diff --git a/docs/source/api_reference/api_project.rst b/docs/source/api_reference/api_project.rst index e9384102e..4463a43d0 100644 --- a/docs/source/api_reference/api_project.rst +++ b/docs/source/api_reference/api_project.rst @@ -31,3 +31,5 @@ Projects .. automethod:: superannotate.SAClient.list_categories .. automethod:: superannotate.SAClient.remove_categories .. automethod:: superannotate.SAClient.remove_users_from_project +.. automethod:: superannotate.SAClient.grant_project_user_permissions +.. automethod:: superannotate.SAClient.revoke_project_user_permissions diff --git a/docs/source/conf.py b/docs/source/conf.py index 852b95abd..09f0ad729 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,7 +6,7 @@ # -- Project information ----------------------------------------------------- project = "SuperAnnotate Python SDK" -copyright = "2021, SuperAnnotate AI" +copyright = "2026, SuperAnnotate AI" author = "SuperAnnotate AI" # The full version, including alpha/beta/rc tags diff --git a/pytest.ini b/pytest.ini index d9f7f6cc3..c0f66b58e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,4 +3,4 @@ minversion = 3.7 log_cli=true python_files = test_*.py ;pytest_plugins = ['pytest_profiling'] -;addopts = -n 6 --dist loadscope +addopts = -n 6 --dist loadscope diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 8813c9add..c1346ca98 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.4" +__version__ = "4.5.5" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/analytics/aggregators.py b/src/superannotate/lib/app/analytics/aggregators.py index b3ef289f2..1bd84209f 100644 --- a/src/superannotate/lib/app/analytics/aggregators.py +++ b/src/superannotate/lib/app/analytics/aggregators.py @@ -384,9 +384,7 @@ def aggregate_image_annotations_as_df(self, annotations_paths: list[str]): annotation_json = None with open(annotation_path) as fp: annotation_json = json.load(fp) - parts = Path(annotation_path).name.split(self._annotation_suffix) row_data = self.__fill_image_metadata(row_data, annotation_json["metadata"]) - annotation_instance_id = 0 # include comments for annotation in annotation_json["comments"]: @@ -433,10 +431,8 @@ def aggregate_image_annotations_as_df(self, annotations_paths: list[str]): if Path(annotation_path).parent != Path(self.project_root): folder_name = Path(annotation_path).parent.name instance_row.folderName = folder_name - num_added = 0 if not attributes: rows.append(instance_row) - num_added = 1 else: for attribute in attributes: attribute_row = copy.copy(instance_row) @@ -469,10 +465,6 @@ def aggregate_image_annotations_as_df(self, annotations_paths: list[str]): attribute_row.attributeName = attribute_name rows.append(attribute_row) - num_added += 1 - - if num_added > 0: - annotation_instance_id += 1 df = pd.DataFrame([row.__dict__ for row in rows], dtype=object) df = df.astype({"probability": float}) diff --git a/src/superannotate/lib/app/analytics/common.py b/src/superannotate/lib/app/analytics/common.py index 85222d98b..dc944bc25 100644 --- a/src/superannotate/lib/app/analytics/common.py +++ b/src/superannotate/lib/app/analytics/common.py @@ -3,7 +3,6 @@ from pathlib import Path import pandas as pd -import plotly.express as px from lib.core.exceptions import AppException logger = logging.getLogger("sa") @@ -558,50 +557,3 @@ def consensus(df, item_name, annot_type): instance_id += 1 return image_data - - -def consensus_plot(consensus_df, *_, **__): - plot_data = consensus_df.copy() - - # annotator-wise boxplot - annot_box_fig = px.box( - plot_data, - x="creatorEmail", - y="score", - points="all", - color="creatorEmail", - color_discrete_sequence=px.colors.qualitative.Dark24, - ) - annot_box_fig.show() - - # project-wise boxplot - project_box_fig = px.box( - plot_data, - x="folderName", - y="score", - points="all", - color="folderName", - color_discrete_sequence=px.colors.qualitative.Dark24, - ) - project_box_fig.show() - - # scatter plot of score vs area - fig = px.scatter( - plot_data, - x="area", - y="score", - color="className", - symbol="creatorEmail", - facet_col="folderName", - color_discrete_sequence=px.colors.qualitative.Dark24, - hover_data={ - "className": False, - "itemName": True, - "folderName": False, - "area": False, - "score": False, - }, - ) - fig.for_each_annotation(lambda a: a.update(text=a.text.split("=")[-1])) - fig.for_each_trace(lambda t: t.update(name=t.name.split("=")[-1])) - fig.show() diff --git a/src/superannotate/lib/app/input_converters/conversion.py b/src/superannotate/lib/app/input_converters/conversion.py index 57f28855f..dd4863618 100644 --- a/src/superannotate/lib/app/input_converters/conversion.py +++ b/src/superannotate/lib/app/input_converters/conversion.py @@ -94,16 +94,6 @@ def _passes_type_sanity(params_info): ) -def _passes_list_members_type_sanity(lists_info): - for _list in lists_info: - for _list_member in _list[0]: - if not isinstance(_list_member, _list[2]): - raise AppException( - "'%s' should be list of '%s', but contains '%s'" - % (_list[1], _list[2], type(_list_member)) - ) - - def _passes_value_sanity(values_info): for value in values_info: if value[0] not in value[2]: diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py deleted file mode 100644 index cd213c0b3..000000000 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -COCO to SA conversion method -""" - -import logging - -from .coco_api import _maskfrRLE -from .coco_api import decode - -logger = logging.getLogger("sa") - - -def annot_to_bitmask(annot): - if isinstance(annot["counts"], list): - bitmask = _maskfrRLE(annot) - elif isinstance(annot["counts"], str): - bitmask = decode(annot) - - return bitmask diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_pixel_to_coco.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_pixel_to_coco.py deleted file mode 100644 index 92af16383..000000000 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_pixel_to_coco.py +++ /dev/null @@ -1,31 +0,0 @@ -import cv2 as cv -import numpy as np - -from .coco_api import _area -from .coco_api import _toBbox - - -def __instance_object_commons_per_instance(instance, id_generator, flat_mask): - if "parts" not in instance: - return None - - anno_id = next(id_generator) - parts = [int(part["color"][1:], 16) for part in instance["parts"]] - category_id = instance["classId"] - - instance_bitmask = np.isin(flat_mask, parts) - - databytes = instance_bitmask * np.uint8(255) - contours, _ = cv.findContours(databytes, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_NONE) - bbox = list(_toBbox(instance_bitmask)) - area = int(_area(instance_bitmask.astype(np.uint8))) - return (bbox, area, contours, category_id, anno_id) - - -def instance_object_commons(instances, id_generator, flat_mask): - commons_lst = [ - __instance_object_commons_per_instance(x, id_generator, flat_mask) - for x in instances - ] - commons_lst = [x for x in commons_lst if x is not None] - return commons_lst diff --git a/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_strategies.py b/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_strategies.py index 82288a599..428f09f5e 100644 --- a/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_strategies.py +++ b/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_strategies.py @@ -24,11 +24,11 @@ def to_sa_format(self): == "supervisely_keypoint_detection_to_sa_vector" ): meta_json = json.load(open(self.export_root / "meta.json")) - sa_jsons = self.conversion_algorithm( + self.conversion_algorithm( json_files, classes_id_map, meta_json, self.output_dir ) else: - sa_jsons = self.conversion_algorithm( + self.conversion_algorithm( json_files, classes_id_map, self.task, self.output_dir ) (self.output_dir / "classes").mkdir(exist_ok=True) diff --git a/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_helper.py b/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_helper.py index 7c8795ffb..24d73b9e9 100644 --- a/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_helper.py +++ b/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_helper.py @@ -38,17 +38,6 @@ def _iou(bbox1, bbox2): ) -def _get_image_shape_from_xml(file_path): - with open(os.path.splitext(file_path)[0] + ".xml") as f: - tree = ET.parse(f) - - size = tree.find("size") - width = int(size.find("width").text) - height = int(size.find("height").text) - - return height, width - - def _get_image_metadata(file_path): with open(os.path.splitext(file_path)[0] + ".xml") as f: tree = ET.parse(f) diff --git a/src/superannotate/lib/app/input_converters/sa_conversion.py b/src/superannotate/lib/app/input_converters/sa_conversion.py deleted file mode 100644 index de2e0244a..000000000 --- a/src/superannotate/lib/app/input_converters/sa_conversion.py +++ /dev/null @@ -1,8 +0,0 @@ -import logging -import shutil - -logger = logging.getLogger("sa") - - -def copy_file(src_path, dst_path): - shutil.copy(src_path, dst_path) diff --git a/src/superannotate/lib/app/interface/responses.py b/src/superannotate/lib/app/interface/responses.py new file mode 100644 index 000000000..a4bc1501b --- /dev/null +++ b/src/superannotate/lib/app/interface/responses.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from collections.abc import Callable +from collections.abc import Iterator +from typing import Generic +from typing import overload +from typing import TypeVar + +T = TypeVar("T") + + +class BaseResult(list, Generic[T]): + """A generic list-like wrapper for results with lazy loading support. + + Inherits from ``list`` for full backward compatibility with code that + expects a real list (``isinstance(x, list)``, JSON serializers, etc.). + Data is fetched lazily on first access. + """ + + def __init__(self, data_fetcher: Callable[[], list[T]]) -> None: + super().__init__() + self._data_fetcher = data_fetcher + self._loaded = False + + def _ensure_data(self) -> None: + """Lazily fetch data if not already loaded.""" + if not self._loaded: + list.extend(self, self._data_fetcher()) + self._loaded = True + + def data(self) -> list[T]: + self._ensure_data() + return list(self) + + def __iter__(self) -> Iterator[T]: + self._ensure_data() + return list.__iter__(self) + + def __len__(self) -> int: + self._ensure_data() + return list.__len__(self) + + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> list[T]: ... + + def __getitem__(self, index: int | slice) -> T | list[T]: + self._ensure_data() + return list.__getitem__(self, index) + + def __repr__(self) -> str: + self._ensure_data() + return list.__repr__(self) + + def __bool__(self) -> bool: + self._ensure_data() + return list.__len__(self) > 0 + + def __contains__(self, item: object) -> bool: + self._ensure_data() + return list.__contains__(self, item) + + def __eq__(self, other: object) -> bool: + self._ensure_data() + return list.__eq__(self, other) + + __hash__ = None # type: ignore[assignment] + + +class QueryResult(BaseResult[dict]): + """A list-like wrapper for query results that supports .count() method. + + This class wraps a list of query results while maintaining full backward + compatibility with list-like operations (iteration, indexing, len()). + Data is fetched lazily - only when accessed. Calling .count() does not + trigger data fetching. + """ + + def __init__( + self, + data_fetcher: Callable[[], list[dict]], + count_fetcher: Callable[[], int], + ) -> None: + super().__init__(data_fetcher) + self._count_fetcher = count_fetcher + + def count(self) -> int: + """Return the count of items matching the query from the server. + + This method does not trigger data fetching - it makes a separate + lightweight API call to get only the count. + """ + return self._count_fetcher() diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index dae44d8a6..3a9387e2a 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -11,6 +11,7 @@ import warnings from collections.abc import Callable from collections.abc import Iterable +from functools import partial from pathlib import Path from typing import Annotated from typing import Any @@ -50,7 +51,12 @@ from lib.core.conditions import Condition from lib.core.jsx_conditions import Filter, OperatorEnum from lib.core.conditions import EmptyCondition -from lib.core.entities import AttachmentEntity, FolderEntity, BaseItemEntity +from lib.core.entities import ( + AttachmentEntity, + FolderEntity, + BaseItemEntity, + ProjectEntity, +) from lib.core.entities import SettingEntity from lib.core.entities.classes import AnnotationClassEntity from lib.core.entities.classes import AttributeGroup @@ -62,11 +68,9 @@ from lib.core.enums import ClassTypeEnum from lib.core.exceptions import AppException from lib.core.types import PriorityScoreEntity -from lib.core.types import Project from lib.infrastructure.annotation_adapter import BaseMultimodalAnnotationAdapter from lib.infrastructure.annotation_adapter import MultimodalSmallAnnotationAdapter from lib.infrastructure.annotation_adapter import MultimodalLargeAnnotationAdapter -from lib.infrastructure.utils import extract_project_folder from lib.infrastructure.validators import wrap_error from lib.app.serializers import WMProjectSerializer from lib.core.entities.work_managament import WMUserTypeEnum @@ -80,6 +84,8 @@ from lib.infrastructure.query_builder import QueryBuilderChain from lib.infrastructure.query_builder import FieldValidationHandler +from lib.app.interface.responses import QueryResult + logger = logging.getLogger("sa") NotEmptyStr = Annotated[str, StringConstraints(strict=True, min_length=1)] @@ -101,8 +107,6 @@ ANNOTATION_TYPE = Literal["bbox", "polygon", "point", "tag"] -ANNOTATOR_ROLE = Literal["Admin", "Annotator", "QA"] - FOLDER_STATUS = Literal["NotStarted", "InProgress", "Completed", "OnHold"] @@ -140,7 +144,7 @@ class ItemContext: def __init__( self, controller: Controller, - project: Project, + project: ProjectEntity, folder: FolderEntity, item: BaseItemEntity, overwrite: bool = True, @@ -152,6 +156,7 @@ def __init__( self._annotation_adapter: BaseMultimodalAnnotationAdapter | None = None self._overwrite = overwrite self._annotation: dict | None = None + self._set_component_called = False def _set_small_annotation_adapter(self, annotation: dict | None = None): self._annotation_adapter = MultimodalSmallAnnotationAdapter( @@ -173,7 +178,7 @@ def _set_large_annotation_adapter(self, annotation: dict | None = None): ) @property - def annotation_adapter(self) -> BaseMultimodalAnnotationAdapter: + def annotation_adapter(self) -> BaseMultimodalAnnotationAdapter | None: if self._annotation_adapter is None: res = self.controller.service_provider.annotations.get_upload_chunks( project=self.project, item_ids=[self.item.id] @@ -213,7 +218,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): if exc_type: return False - self.save() + if self._set_component_called: + self.save() return True def save(self): @@ -222,6 +228,7 @@ def save(self): else: self._set_small_annotation_adapter(self.annotation) self._annotation_adapter.save() + self._set_component_called = False def get_metadata(self): """ @@ -233,7 +240,7 @@ def get_metadata(self): Request Example: :: - with client.item_context(("project_name", "folder_name"), 12345) as context: + with sa_client.item_context(("project_name", "folder_name"), 12345) as context: metadata = context.get_metadata() print(metadata) """ @@ -252,7 +259,7 @@ def get_component_value(self, component_id: str): Request Example: :: - with client.item_context((101, 202), "item_name") as context: # (101, 202) project and folder IDs + with sa_client.item_context((101, 202), "item_name") as context: # (101, 202) project and folder IDs value = context.get_component_value("component_id") print(value) """ @@ -274,13 +281,14 @@ def set_component_value(self, component_id: str, value: Any): Request Example: :: - with client.item_context("project_name/folder_name", "item_name") as item_context: + with sa_client.item_context("project_name/folder_name", "item_name") as item_context: metadata = item_context.get_metadata() value = item_context.get_component_value("component_id") item_context.set_component_value("component_id", value) """ self.annotation_adapter.set_component_value(component_id, value) + self._set_component_called = True return self @@ -313,17 +321,17 @@ def get_project_by_id(self, project_id: int): :return: project metadata :rtype: dict """ - response = self.controller.get_project_by_id(project_id=project_id) + project = self.controller.get_project(project_id) - return ProjectSerializer(response.data).serialize() + return ProjectSerializer(project).serialize() def get_folder_by_id(self, project_id: int, folder_id: int): """Returns the folder metadata - :param project_id: the id of the project + :param project_id: the ID of the project :type project_id: int - :param folder_id: the id of the folder + :param folder_id: the ID of the folder :type folder_id: int :return: folder metadata @@ -363,13 +371,12 @@ def get_item_by_id( :return: item metadata :rtype: dict """ - project_response = self.controller.get_project_by_id(project_id=project_id) - project_response.raise_for_status() + project = self.controller.get_project(project_id) if ( include and "categories" in include - and project_response.data.type != ProjectType.MULTIMODAL.value + and project.type != ProjectType.MULTIMODAL.value ): raise AppException( "The 'categories' option in the 'include' field is only supported for Multimodal projects." @@ -386,12 +393,12 @@ def get_item_by_id( include.remove("custom_metadata") item = self.controller.items.get_item_by_id( - item_id=item_id, project=project_response.data, include=include + item_id=item_id, project=project, include=include ) if include_custom_metadata: item_custom_fields = self.controller.custom_fields.list_fields( - project=project_response.data, item_ids=[item.id] + project=project, item_ids=[item.id] ) item.custom_metadata = item_custom_fields[item.id] @@ -446,7 +453,7 @@ def get_user_metadata( Request Example: :: - client.get_user_metadata( + sa_client.get_user_metadata( "example@email.com", include=["custom_fields"] ) @@ -493,7 +500,7 @@ def set_user_custom_field(self, pk: int | str, custom_field_name: str, value: An Request Example: :: - client.set_user_custom_field( + sa_client.set_user_custom_field( "example@email.com", custom_field_name="due_date", value=1738671238.7 @@ -591,7 +598,7 @@ def list_users( :: user_filters = {"custom_field__accuracy score 30D__lt": 90} - client.list_users(include=["custom_fields"], **user_filters) + sa_client.list_users(include=["custom_fields"], **user_filters) :type filters: UserFilters, optional @@ -602,7 +609,7 @@ def list_users( Request Example: :: - client.list_users( + sa_client.list_users( email__contains="@superannotate.com", include=["custom_fields"], state__in=["Confirmed"] @@ -625,6 +632,7 @@ def list_users( "role": "TeamOwner", "state": "Confirmed", "team_id": 44311, + "user_permissions": [], } ] @@ -633,12 +641,12 @@ def list_users( # Project level scores - scores = client.list_users( + scores = sa_client.list_users( include=["custom_fields"], project="my_multimodal", email__contains="@superannotate.com", custom_field__speed__gte=90, - custom_field__weight__lte=1, + custom_field__weight__lte=1 ) Response Example: @@ -653,9 +661,17 @@ def list_users( "custom_fields": {"speed": 92, "weight": 0.8}, "email": "example@superannotate.com", "id": 715121, + 'permissions': { + 'allow_orchestrate': 0, + 'allow_run_explore': 0, + 'allow_view_sdk_token': 0, + 'paused': 0 + }, "role": "Annotator", "state": "Confirmed", "team_id": 1234, + "categories": None, + "user_permissions": [], } ] @@ -669,7 +685,7 @@ def list_users( "custom_field__speed score 7D__lt": 15 } - scores = client.list_users( + scores = sa_client.list_users( include=["custom_fields"], email__contains="@superannotate.com", role="Contributor", @@ -700,15 +716,103 @@ def list_users( "role": "Contributor", "state": "Confirmed", "team_id": 1234, + "user_permissions": [], } ] + + ``User Permissions`` + Every returned user includes a ``user_permissions`` list. + + The contents depend on whether the ``project`` parameter is provided: + + - If ``project`` is specified, ``user_permissions`` contains the + permissions granted to the user within that project + (e.g. ``Download``). + + - If ``project`` is omitted, ``user_permissions`` contains the + team-level permissions granted to the user + (e.g. ``Remove Contributors from team``). + + Request Example: + :: + + project_user = sa_client.list_users( + project="my_multimodal", + email="test@superannotate.com", + ) + + Response Example: + :: + + [ + { + 'createdAt': '2026-05-19T08:28:55.000Z', + 'custom_fields': {}, + 'email': 'test@superannotate.com', + 'id': 1622408, + 'permissions': { + 'allow_orchestrate': 0, + 'allow_run_explore': 0, + 'allow_view_sdk_token': 0, + 'paused': 0 + }, + 'role': 'ProjectAdmin', + 'state': 'Confirmed', + 'team_id': 32022, + 'updatedAt': '2026-05-19T08:28:55.000Z', + 'categories': None, + 'user_permissions': [ + { + 'id': 28, + 'name': 'Download', + 'createdAt': '2026-05-12T12:47:41.000Z', + 'updatedAt': '2026-05-12T12:47:41.000Z' + } + ], + } + ] + + + Request Example: + :: + + project_user = sa_client.list_users( + email="test@superannotate.com", + ) + + Response Example: + :: + + [ + { + 'createdAt': '2026-05-19T08:28:55.000Z', + 'custom_fields': {}, + 'email': 'test@superannotate.com', + 'id': 1622408, + 'role': 'Contributor', + 'state': 'Confirmed', + 'team_id': 32022, + 'updatedAt': '2026-05-19T08:28:55.000Z', + 'user_permissions': [ + { + 'id': 21, + 'name': 'Remove Contributors from team', + 'createdAt': '2026-05-12T12:47:41.000Z', + 'updatedAt': '2026-05-12T12:47:41.000Z' + }, + { + 'id': 22, + 'name': 'View Contributors’ scores', + 'createdAt': '2026-05-12T12:45:41.000Z', + 'updatedAt': '2026-05-12T12:46:41.000Z' + }, + ], + } + ] """ if project is not None: - if isinstance(project, int): - project = self.controller.get_project_by_id(project).data - else: - project = self.controller.get_project(project) + project = self.controller.get_project(project) response = BaseSerializer.serialize_iterable( self.controller.work_management.list_users( project=project, include=include, **filters @@ -796,7 +900,7 @@ def get_user_scores( Request Example: :: - client.get_user_scores( + sa_client.get_user_scores( project=("my_multimodal", "folder1"), item="item1", scored_user="example@superannotate.com", @@ -881,7 +985,7 @@ def set_user_scores( Request Example: :: - client.set_user_scores( + sa_client.set_user_scores( project=("my_multimodal", "folder1"), item_=12345, scored_user="example@superannotate.com", @@ -934,13 +1038,13 @@ def set_contributors_categories( Request Example: :: - client.set_contributor_categories( + sa_client.set_contributor_categories( project="product-review-mm", contributors=["test@superannotate.com","contributor@superannotate.com"], categories=["Shoes", "T-Shirt"] ) - client.set_contributor_categories( + sa_client.set_contributor_categories( project="product-review-mm", contributors=["test@superannotate.com","contributor@superannotate.com"] categories="*" @@ -949,11 +1053,7 @@ def set_contributors_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) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) self.controller.check_multimodal_project_categorization(project) self.controller.work_management.set_remove_contributor_categories( @@ -984,13 +1084,13 @@ def remove_contributors_categories( Request Example: :: - client.remove_contributor_categories( + sa_client.remove_contributor_categories( project="product-review-mm", contributors=["test@superannotate.com","contributor@superannotate.com"], categories=["Shoes", "T-Shirt", "Jeans"] ) - client.remove_contributor_categories( + sa_client.remove_contributor_categories( project="product-review-mm", contributors=["test@superannotate.com","contributor@superannotate.com"] categories="*" @@ -999,11 +1099,7 @@ def remove_contributors_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) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) self.controller.check_multimodal_project_categorization(project) self.controller.work_management.set_remove_contributor_categories( @@ -1013,6 +1109,122 @@ def remove_contributors_categories( operation="remove", ) + def grant_project_user_permissions( + self, + project: NotEmptyStr | int, + permissions: list[NotEmptyStr] | Literal["*"], + user: int | str, + ) -> None: + """ + Grants permissions for a project contributor in the specified project. + Accepts "*" to indicate all available permissions in the project. + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param permissions: Specifies which permissions to grant. + Accepts "*" to indicate all available permissions in the project. + + Possible values are + + - "Download": Only available for Project Admins. + Allows project admins to export project data. + :type permissions: Union[List[str], Literal["*"]] + + :param user: Project user ID or email to grant permissions to. + :type user: Union[int, str] + + :rtype: None + + Request Example: + :: + + # To grant Download permission by email: + sa_client.grant_project_user_permissions( + project="my_project", + permissions=["Download"], + user="admin1@superannotate.com" + ) + + # To grant all permissions by project user ID: + sa_client.grant_project_user_permissions( + project=12345, + permissions="*", + user=101 + ) + """ + if not permissions: + raise AppException("Permission(s) cannot be empty.") + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.work_management.edit_project_user_permissions( + project=project, + user=user, + permissions=permissions, + operation="grant", + ) + + def revoke_project_user_permissions( + self, + project: NotEmptyStr | int, + permissions: list[NotEmptyStr] | Literal["*"], + user: int | str, + ) -> None: + """ + Revokes permissions for a project contributor in the specified project. + Accepts "*" to indicate all available permissions in the project. + + :param project: The name or ID of the project. + :type project: Union[NotEmptyStr, int] + + :param permissions: Specifies which permissions to revoke. + Accepts "*" to indicate all available permissions in the project. + + Possible values are + + - "Download": Only available for Project Admins. + Allows project admins to export project data. + :type permissions: Union[List[str], Literal["*"]] + + :param user: Project user ID or email to revoke permissions from. + :type user: Union[int, str] + + :rtype: None + + Request Example: + :: + + # To revoke Download permission by email: + sa_client.revoke_project_user_permissions( + project="my_project", + permissions=["Download"], + user="admin1@superannotate.com" + ) + + # To revoke all permissions by project user ID: + sa_client.revoke_project_user_permissions( + project=12345, + permissions="*", + user=101 + ) + """ + if not permissions: + raise AppException("Permission(s) cannot be empty.") + project = ( + self.controller.get_project_by_id(project).data + if isinstance(project, int) + else self.controller.get_project(project) + ) + self.controller.work_management.edit_project_user_permissions( + project=project, + user=user, + permissions=permissions, + operation="revoke", + ) + def get_component_config(self, project: NotEmptyStr | int, component_id: str): """ Retrieves the configuration for a given project and component ID. @@ -1048,18 +1260,14 @@ def retrieve_context( context = component.get("context") if context is None or context == "": return False, None - return True, json.loads(component.get("context")) + return True, json.loads(component.get("context")) # noqa except KeyError as e: logger.debug("Got key error:", component_data) raise e return False, None - project = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) if project.type != ProjectType.MULTIMODAL: raise AppException( "This function is only supported for Multimodal projects." @@ -1285,8 +1493,8 @@ def create_project( def clone_project( self, - project_name: NotEmptyStr | dict, - from_project: NotEmptyStr | dict, + project_name: NotEmptyStr, + from_project: NotEmptyStr | int, project_description: NotEmptyStr | None = None, copy_annotation_classes: bool | None = True, copy_settings: bool | None = True, @@ -1300,7 +1508,7 @@ def clone_project( :param project_name: new project's name :type project_name: str - :param from_project: the name of the project being used for duplication + :param from_project: the name or ID of the project being used for duplication :type from_project: str :param project_description: the new project's description. If None, from_project's @@ -1412,7 +1620,7 @@ def create_categories( Request Example: :: - client.create_categories( + sa_client.create_categories( project="product-review-mm", categories=["Shoes", "T-Shirt"] ) @@ -1420,11 +1628,7 @@ def create_categories( 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) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) self.controller.check_multimodal_project_categorization(project) response = ( @@ -1449,7 +1653,7 @@ def list_categories(self, project: NotEmptyStr | int): Request Example: :: - client.list_categories( + sa_client.list_categories( project="product-review-mm" ) @@ -1474,11 +1678,7 @@ def list_categories(self, project: NotEmptyStr | int): ] """ - project = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) self.controller.check_multimodal_project_categorization(project) response = ( @@ -1506,13 +1706,13 @@ def remove_categories( Request Example: :: - client.remove_categories( + sa_client.remove_categories( project="product-review-mm", categories=["Shoes", "T-Shirt"] ) # To remove all categories - client.remove_categories( + sa_client.remove_categories( project="product-review-mm", categories="*" ) @@ -1520,11 +1720,7 @@ def remove_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) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) self.controller.check_multimodal_project_categorization(project) query = EmptyQuery() @@ -1554,11 +1750,11 @@ def remove_categories( f"{len(response.data)} categories successfully removed from the project." ) - def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr): + def create_folder(self, project: NotEmptyStr | int, folder_name: NotEmptyStr): """ Create a new folder in the project. - :param project: project name + :param project: project name or ID :type project: str :param folder_name: the new folder's name @@ -1580,28 +1776,32 @@ def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr): if res.errors: raise AppException(res.errors) - def delete_project(self, project: NotEmptyStr | dict): + def delete_project(self, project: NotEmptyStr | int): """Deletes the project - :param project: project name + :param project: project name or ID :type project: str """ - name = project - if isinstance(project, dict): - name = project["name"] - self.controller.projects.delete(name=name) - - def rename_project(self, project: NotEmptyStr, new_name: NotEmptyStr): + try: + project = self.controller.get_project(project) + except AppException as e: + if str(e) == "Project not found.": + return + raise + self.controller.projects.delete(name=project.name) + + def rename_project(self, project: NotEmptyStr | int, new_name: NotEmptyStr): """Renames the project - :param project: project name + :param project: project name or ID :type project: str :param new_name: project's new name :type new_name: str """ - old_name = project - project = self.controller.get_project(old_name) # noqa + + project = self.controller.get_project(project) + old_name = project.name project.name = new_name response = self.controller.projects.update(project) if response.errors: @@ -1613,7 +1813,7 @@ def rename_project(self, project: NotEmptyStr, new_name: NotEmptyStr): def get_folder_metadata( self, - project: NotEmptyStr, + project: NotEmptyStr | int, folder_name: NotEmptyStr, include_contributors: bool = False, ): @@ -1622,7 +1822,7 @@ def get_folder_metadata( Returns folder metadata. Optionally includes a list of contributors that are currently assigned to the folder. - :param project: project name + :param project: project name or ID :type project: str :param folder_name: folder's name @@ -1697,10 +1897,12 @@ def get_folder_metadata( exclude={"completedCount", "is_root"}, by_alias=False ) - def delete_folders(self, project: NotEmptyStr, folder_names: list[NotEmptyStr]): + def delete_folders( + self, project: NotEmptyStr | int, folder_names: list[NotEmptyStr] + ): """Delete folder in project. - :param project: project name + :param project: project name or ID :type project: str :param folder_names: to be deleted folders' names @@ -1749,7 +1951,12 @@ def search_folders( :rtype: list of strs or dicts """ - + warnings.warn( + DeprecationWarning( + "This function search_folders() will be deprecated and removed in version 4.6.0 \n" + "Recommended replacement:list_folders()" + ) + ) project = self.controller.get_project(project) condition = EmptyCondition() if folder_name: @@ -1820,7 +2027,7 @@ def list_folders( Request Example: :: - client.list_folders( + sa_client.list_folders( project="test_project", status="NotStarted" ) @@ -1851,11 +2058,7 @@ def list_folders( } ] """ - project_entity = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) + project_entity = self.controller.get_project(project) valid_fields = FolderFilters.__annotations__ chain = QueryBuilderChain( @@ -1877,7 +2080,7 @@ def list_folders( def get_project_metadata( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int, include_annotation_classes: bool | None = False, include_settings: bool | None = False, include_workflow: bool | None = False, @@ -1887,7 +2090,7 @@ def get_project_metadata( ): """Returns project metadata - :param project: project name + :param project: project name or ID :type project: str :param include_annotation_classes: enables project annotation classes output under @@ -1919,7 +2122,7 @@ def get_project_metadata( Request Example: :: - client.get_project_metadata( + sa_client.get_project_metadata( project="Medical Annotations", include_workflow=True, include_custom_fields=True @@ -1968,8 +2171,7 @@ def get_project_metadata( } } """ - project_name, _ = extract_project_folder(project) - project_entity = self.controller.get_project(project_name) + project_entity = self.controller.get_project(project) response = self.controller.projects.get_metadata( project_entity, include_annotation_classes, @@ -1988,19 +2190,18 @@ def get_project_metadata( ) return project - def get_project_settings(self, project: NotEmptyStr | dict): + def get_project_settings(self, project: NotEmptyStr | int): """Gets project's settings. Return value example: [{ "attribute" : "Brightness", "value" : 10, ...},...] - :param project: project name or metadata - :type project: str or dict + :param project: project name or ID + :type project: str or ID :return: project settings :rtype: list of dicts """ - project_name, _ = extract_project_folder(project) - project = self.controller.projects.get_by_name(project_name).data + project = self.controller.get_project(project) settings = self.controller.projects.list_settings(project).data settings = [ SettingsSerializer(attribute.model_dump()).serialize() @@ -2008,13 +2209,13 @@ def get_project_settings(self, project: NotEmptyStr | dict): ] return settings - def get_project_steps(self, project: str | dict): + def get_project_steps(self, project: NotEmptyStr | int): """Gets project's steps. Return value example: [{ "step" : , "className" : , "tool" : , ...},...] - :param project: project name or metadata - :type project: str or dict + :param project: project name or ID + :type project: str or ID :return: A list of step dictionaries, or a dictionary containing both steps and their connections (for Keypoint workflows). @@ -2068,19 +2269,18 @@ def get_project_steps(self, project: str | dict): ] } """ - project_name, _ = extract_project_folder(project) - project = self.controller.get_project(project_name) + project = self.controller.get_project(project) steps = self.controller.projects.list_steps(project) if steps.errors: raise AppException(steps.errors) return steps.data def search_annotation_classes( - self, project: NotEmptyStr | dict, name_contains: str | None = None + self, project: NotEmptyStr | int, name_contains: str | None = None ): """Searches annotation classes by name_prefix (case-insensitive) - :param project: project name + :param project: project name or ID :type project: str :param name_contains: search string. Returns those classes, @@ -2090,8 +2290,7 @@ def search_annotation_classes( :return: annotation classes of the project :rtype: list of dicts """ - project_name, _ = extract_project_folder(project) - project = self.controller.get_project(project_name) + project = self.controller.get_project(project) condition = Condition("project_id", project.id, EQ) if name_contains: condition &= Condition("name", name_contains, EQ) & Condition( @@ -2121,7 +2320,7 @@ def get_annotation_class( Request Example: :: - classes = client.get_annotation_class( + classes = sa_client.get_annotation_class( project="classes", annotation_class="Example_class" ) @@ -2345,10 +2544,10 @@ def update_annotation_class( by_alias=False, use_enum_names=False ) - def set_project_status(self, project: NotEmptyStr, status: PROJECT_STATUS): + def set_project_status(self, project: NotEmptyStr | int, status: PROJECT_STATUS): """Set project status - :param project: project name + :param project: project name or ID :type project: str :param status: status to set. @@ -2391,17 +2590,13 @@ def set_project_custom_field( Request Example: :: - client.set_project_custom_field( + sa_client.set_project_custom_field( project="Medical Annotations", custom_field_name="due_date", value=1738671238.759, ) """ - project = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) self.controller.work_management.set_custom_field_value( entity_id=project.id, field_name=custom_field_name, @@ -2411,14 +2606,17 @@ def set_project_custom_field( ) def set_folder_status( - self, project: NotEmptyStr, folder: NotEmptyStr, status: FOLDER_STATUS + self, + project: NotEmptyStr | int, + folder: NotEmptyStr | int, + status: FOLDER_STATUS, ): """Set folder status - :param project: project name + :param project: project name or ID :type project: str - :param folder: folder name + :param folder: folder name or ID :type folder: str :param status: status to set. \n @@ -2430,7 +2628,8 @@ def set_folder_status( * OnHold :type status: str """ - project, folder = self.controller.get_project_folder((project, folder)) + project = self.controller.get_project(project) + folder = self.controller.get_folder(project, folder) folder.status = constants.FolderStatus(status).value response = self.controller.update(project, folder) if response.errors: @@ -2441,19 +2640,18 @@ def set_folder_status( def set_project_default_image_quality_in_editor( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int, image_quality_in_editor: str | None, ): """Sets project's default image quality in editor setting. - :param project: project name or metadata - :type project: str or dict + :param project: project name or ID + :type project: str or ID :param image_quality_in_editor: new setting value, should be "original" or "compressed" :type image_quality_in_editor: str """ - project_name, _ = extract_project_folder(project) image_quality_in_editor = ImageQuality(image_quality_in_editor).value - project = self.controller.get_project(project_name) + project = self.controller.get_project(project) response = self.controller.projects.set_settings( project=project, settings=[{"attribute": "ImageQuality", "value": image_quality_in_editor}], @@ -2583,7 +2781,7 @@ def assign_folder( """Assigns folder to users. With SDK, the user can be assigned to a role in the project with the share_project function. - :param project_name: project name or metadata of the project + :param project_name: project name of the project :type project_name: str or dict :param folder_name: folder name to assign :type folder_name: str @@ -2623,7 +2821,7 @@ def assign_folder( def upload_images_from_folder_to_project( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], folder_path: NotEmptyStr | Path, extensions: None | ( list[NotEmptyStr] | tuple[NotEmptyStr] @@ -2644,7 +2842,7 @@ def upload_images_from_folder_to_project( function. :param project: project name or folder path (e.g., "project1/folder1") - :type project: str or dict + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param folder_path: from which folder to upload the images :type folder_path: Path-like (str or Path) @@ -2675,7 +2873,7 @@ def upload_images_from_folder_to_project( :rtype: tuple (3 members) of list of strs """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) if annotation_status is not None: warnings.warn( DeprecationWarning( @@ -2702,7 +2900,9 @@ def upload_images_from_folder_to_project( ) exclude_file_patterns = list(set(exclude_file_patterns)) - project_folder_name = project_name + (f"/{folder_name}" if folder_name else "") + project_folder_name = project.name + ( + f"/{folder.name}" if not folder.is_root else "" + ) logger.info( "Uploading all images with extensions %s from %s to project %s. Excluded file patterns are: %s.", @@ -2711,10 +2911,10 @@ def upload_images_from_folder_to_project( project_folder_name, exclude_file_patterns, ) - project = self.controller.get_project(project_name) + use_case = self.controller.upload_images_from_folder_to_project( project=project, - folder_name=folder_name, + folder=folder, folder_path=folder_path, extensions=extensions, annotation_status=annotation_status, @@ -2778,10 +2978,12 @@ def download_image_annotations( raise AppException(res.errors) return res.data - def get_exports(self, project: NotEmptyStr, return_metadata: bool | None = False): + def get_exports( + self, project: NotEmptyStr | int, return_metadata: bool | None = False + ): """Get all prepared exports of the project. - :param project: project name + :param project: project name or ID :type project: str :param return_metadata: return metadata of images instead of names @@ -2790,14 +2992,15 @@ def get_exports(self, project: NotEmptyStr, return_metadata: bool | None = False :return: names or metadata objects of the all prepared exports of the project :rtype: list of strs or dicts """ + project = self.controller.get_project(project) response = self.controller.get_exports( - project_name=project, return_metadata=return_metadata + project=project, return_metadata=return_metadata ) return response.data def prepare_export( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int, folder_names: list[NotEmptyStr] | None = None, annotation_statuses: list[str] | None = None, include_fuse: bool | None = False, @@ -2807,7 +3010,7 @@ def prepare_export( """Prepare annotations and classes.json for export. Original and fused images for images with annotations can be included with include_fuse flag. - :param project: project name + :param project: project name or ID :type project: str :param folder_names: names of folders to include in the export. If None, whole project will be exported @@ -2836,20 +3039,20 @@ def prepare_export( Request Example: :: - client = SAClient() + sa_client = SAClient() - export = client.prepare_export( + export = sa_client.prepare_export( project = "Project Name", folder_names = ["Folder 1", "Folder 2"], annotation_statuses = ["Completed","QualityCheck"], format = "CSV" ) - client.download_export("Project Name", export, "path_to_download") + sa_client.download_export("Project Name", export, "path_to_download") """ - project_name, folder_name = extract_project_folder(project) + project = self.controller.get_project(project) if folder_names is None: - folders = [folder_name] if folder_name else [] + folders = [] else: folders = folder_names integration_name = kwargs.get("integration_name") @@ -2870,7 +3073,7 @@ def prepare_export( elif export_type == "jsonl": _export_type = 4 response = self.controller.prepare_export( - project_name=project_name, + project=project, folder_names=folders, include_fuse=include_fuse, only_pinned=only_pinned, @@ -2901,22 +3104,18 @@ def delete_exports( :: # To delete a specific export - client.delete_exports( + sa_client.delete_exports( project="my_project", exports=["TestProject_Jan_30_2026_12_09"] ) # To delete all exports in the project - client.delete_exports( + sa_client.delete_exports( project="my_project", exports="*" ) """ - project_entity = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) + project_entity = self.controller.get_project(project) response = self.controller.delete_exports( project=project_entity, exports=exports ) @@ -2926,12 +3125,12 @@ def delete_exports( def upload_videos_from_folder_to_project( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], folder_path: NotEmptyStr | Path, extensions: None | ( tuple[NotEmptyStr] | list[NotEmptyStr] ) = constants.DEFAULT_VIDEO_EXTENSIONS, - exclude_file_patterns: list[NotEmptyStr] | None = (), + exclude_file_patterns: list[NotEmptyStr] | None = None, recursive_subfolders: bool | None = False, target_fps: int | None = None, start_time: float | None = 0.0, @@ -2946,7 +3145,7 @@ def upload_videos_from_folder_to_project( Only works on Image projects. :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param folder_path: from which folder to upload the videos :type folder_path: Path-like (str or Path) @@ -2983,7 +3182,7 @@ def upload_videos_from_folder_to_project( :rtype: tuple of list of strs """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) if annotation_status is not None: warnings.warn( DeprecationWarning( @@ -3012,8 +3211,8 @@ def upload_videos_from_folder_to_project( video_paths = [str(path) for path in video_paths] response = self.controller.upload_videos( - project_name=project_name, - folder_name=folder_name, + project=project, + folder=folder, paths=video_paths, target_fps=target_fps, start_time=start_time, @@ -3028,7 +3227,7 @@ def upload_videos_from_folder_to_project( def upload_video_to_project( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], video_path: NotEmptyStr | Path, target_fps: int | None = None, start_time: float | None = 0.0, @@ -3043,7 +3242,7 @@ def upload_video_to_project( Only works on Image projects. :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param video_path: video to upload :type video_path: Path-like (str or Path) @@ -3071,7 +3270,7 @@ def upload_video_to_project( :rtype: list of strs """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) if annotation_status is not None: warnings.warn( DeprecationWarning( @@ -3080,8 +3279,8 @@ def upload_video_to_project( ) ) response = self.controller.upload_videos( - project_name=project_name, - folder_name=folder_name, + project=project, + folder=folder, paths=[video_path], target_fps=target_fps, start_time=start_time, @@ -3181,7 +3380,7 @@ def create_annotation_class( "name": "Description" } ] - client.create_annotation_class( + sa_client.create_annotation_class( project="Image Project", name="Example Class", color="#F9E0FA", @@ -3226,11 +3425,11 @@ def create_annotation_class( ) def delete_annotation_class( - self, project: NotEmptyStr, annotation_class: dict | NotEmptyStr + self, project: NotEmptyStr | int, annotation_class: dict | NotEmptyStr ): """Deletes annotation class from project - :param project: project name + :param project: project name or ID :type project: str :param annotation_class: annotation class name or metadata @@ -3247,18 +3446,18 @@ def delete_annotation_class( raise AppException(wrap_error(e)) else: annotation_class = AnnotationClassEntity(**annotation_class) - project = self.controller.projects.get_by_name(project).data + project = self.controller.get_project(project) self.controller.annotation_classes.delete( project=project, annotation_class=annotation_class ) def download_annotation_classes_json( - self, project: NotEmptyStr, folder: str | Path + self, project: NotEmptyStr | int, folder: str | Path ): """Downloads project classes.json to folder - :param project: project name + :param project: project name or ID :type project: str :param folder: folder to download to @@ -3268,7 +3467,7 @@ def download_annotation_classes_json( :rtype: str """ - project = self.controller.projects.get_by_name(project).data + project = self.controller.get_project(project) logger.info( f"Downloading classes.json from project {project.name} to folder {str(folder)}." ) @@ -3285,14 +3484,14 @@ def download_annotation_classes_json( def create_annotation_classes_from_classes_json( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int, classes_json: list[AnnotationClassEntity] | str | Path, from_s3_bucket=False, ): """Creates annotation classes in project from a SuperAnnotate format annotation classes.json. - :param project: project name + :param project: project name or ID :type project: str :param classes_json: JSON itself or path to the JSON file @@ -3322,7 +3521,7 @@ def create_annotation_classes_from_classes_json( ).validate_python(classes_json) except ValidationError as _: raise AppException("Couldn't validate annotation classes.") - project = self.controller.projects.get_by_name(project).data + project = self.controller.get_project(project) response = self.controller.annotation_classes.create_multiple( project=project, annotation_classes=annotation_classes, @@ -3333,7 +3532,7 @@ def create_annotation_classes_from_classes_json( def download_export( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int, export: NotEmptyStr | dict, folder_path: str | Path, extract_zip_contents: bool | None = True, @@ -3341,7 +3540,7 @@ def download_export( ): """Download prepared export. - :param project: project name + :param project: project name or ID :type project: str :param export: export name @@ -3357,11 +3556,11 @@ def download_export( :param to_s3_bucket: AWS S3 bucket to use for download. If None then folder_path is in local filesystem. :type to_s3_bucket: Bucket object """ - project_name, _ = extract_project_folder(project) + project = self.controller.get_project(project) export_name = export["name"] if isinstance(export, dict) else export response = self.controller.download_export( - project_name=project_name, + project=project, export_name=export_name, folder_path=folder_path, extract_zip_contents=extract_zip_contents, @@ -3372,14 +3571,14 @@ def download_export( def set_project_steps( self, - project: NotEmptyStr | dict, + project: NotEmptyStr | int, steps: list[dict], connections: list[list[int]] | None = None, ): """Sets project's steps. - :param project: project name or metadata - :type project: str or dict + :param project: project name or ID + :type project: str or int :param steps: new workflow list of dicts :type steps: list of dicts @@ -3438,8 +3637,7 @@ def set_project_steps( ] ) """ - project_name, _ = extract_project_folder(project) - project = self.controller.get_project(project_name) + project = self.controller.get_project(project) response = self.controller.projects.set_steps( project, steps=steps, connections=connections ) @@ -3621,7 +3819,7 @@ def upload_annotations_from_folder_to_project( :rtype: tuple of list of strs """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) if keep_status is not None: warnings.warn( DeprecationWarning( @@ -3629,7 +3827,9 @@ def upload_annotations_from_folder_to_project( "Please use the “set_annotation_statuses” function instead." ) ) - project_folder_name = project_name + (f"/{folder_name}" if folder_name else "") + project_folder_name = project.name + ( + f"/{folder.name}" if not folder.is_root else "" + ) if recursive_subfolders: logger.info( @@ -3648,7 +3848,6 @@ def upload_annotations_from_folder_to_project( logger.info( f"Uploading {len(annotation_paths)} annotations from {folder_path} to the project {project_folder_name}." ) - project, folder = self.controller.get_project_folder(project) response = self.controller.annotations.upload_from_folder( project=project, folder=folder, @@ -3695,7 +3894,6 @@ def upload_image_annotations( """ - _, folder_name = extract_project_folder(project) if keep_status is not None: warnings.warn( DeprecationWarning( @@ -3712,9 +3910,6 @@ def upload_image_annotations( logger.info("Uploading annotations from %s.", annotation_json) with open(annotation_json, "rb") as f: annotation_json = json.load(f) - folder = self.controller.get_folder(project, folder_name) - if not folder: - raise AppException("Folder not found.") items = self.controller.items.list_items(project, folder, name=image_name) image = next(iter(items), None) @@ -3774,7 +3969,7 @@ def consensus( def upload_image_to_project( self, - project: NotEmptyStr, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], img, image_name: NotEmptyStr | None = None, annotation_status: str | None = None, @@ -3785,7 +3980,7 @@ def upload_image_to_project( Sets status of the uploaded image to set_status if it is not None. :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param img: image to upload :type img: io.BytesIO() or Path-like (str or Path) @@ -3806,7 +4001,7 @@ def upload_image_to_project( If None then the default value in project settings will be used. :type image_quality_in_editor: str """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) if annotation_status is not None: warnings.warn( DeprecationWarning( @@ -3815,8 +4010,8 @@ def upload_image_to_project( ) ) response = self.controller.upload_image_to_project( - project_name=project_name, - folder_name=folder_name, + project=project, + folder=folder, image_name=image_name, image=img, annotation_status=annotation_status, @@ -3828,7 +4023,7 @@ def upload_image_to_project( def upload_images_to_project( self, - project: NotEmptyStr, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], img_paths: list[NotEmptyStr], annotation_status: str = "NotStarted", from_s3_bucket=None, @@ -3842,7 +4037,7 @@ def upload_images_to_project( function. :param project: project name or folder path (e.g., "project1/folder1") - :type project: str + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param img_paths: list of Path-like (str or Path) objects to upload :type img_paths: list @@ -3869,11 +4064,11 @@ def upload_images_to_project( :return: uploaded, could-not-upload, existing-images filepaths :rtype: tuple (3 members) of list of strs """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) use_case = self.controller.upload_images_to_project( - project_name=project_name, - folder_name=folder_name, + project=project, + folder=folder, paths=img_paths, annotation_status=annotation_status, image_quality_in_editor=image_quality_in_editor, @@ -3881,7 +4076,8 @@ def upload_images_to_project( ) images_to_upload, existing_items = use_case.images_to_upload - logger.info(f"Uploading {len(images_to_upload)} images to project {project}.") + path = project.name + ("" if folder.is_root else f"/{folder.name}") + logger.info(f"Uploading {len(images_to_upload)} images to project {path}.") uploaded, failed_images = [], [] if not images_to_upload: return uploaded, failed_images, existing_items @@ -3967,6 +4163,7 @@ def validate_annotations( if isinstance(annotations_json, dict): annotation_data = annotations_json else: + annotation_data = None with open(annotations_json, "rb") as f: annotation_data = json.load(f) response = self.controller.validate_annotations(project_type, annotation_data) @@ -3980,13 +4177,13 @@ def validate_annotations( def add_contributors_to_project( self, - project: NotEmptyStr, + project: NotEmptyStr | int, emails: Annotated[list[EmailStr], Field(min_length=1)], role: str, ) -> tuple[list[str], list[str]]: """Add contributors to project. - :param project: project name + :param project: project name or ID :type project: str :param emails: users email @@ -3998,7 +4195,7 @@ def add_contributors_to_project( :return: lists of added, skipped contributors of the project :rtype: tuple (2 members) of lists of strs """ - project = self.controller.projects.get_by_name(project).data + project = self.controller.get_project(project) contributors = [ entities.WMProjectUserEntity( email=email, @@ -4138,7 +4335,9 @@ def upload_priority_scores( """ scores = TypeAdapter(list[PriorityScoreEntity]).validate_python(scores) project, folder = self.controller.get_project_folder(project) - project_folder_name = project.name + "" if folder.is_root else f"/{folder.name}" + project_folder_name = project.name + ( + "" if folder.is_root else f"/{folder.name}" + ) response = self.controller.projects.upload_priority_scores( project, folder, scores, project_folder_name ) @@ -4155,7 +4354,7 @@ def get_integrations(self): Request Example: :: - client.get_integrations() + sa_client.get_integrations() Response Example: @@ -4181,7 +4380,7 @@ def get_integrations(self): def attach_items_from_integrated_storage( self, - project: NotEmptyStr, + project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], integration: NotEmptyStr | IntegrationEntity, folder_path: NotEmptyStr | None = None, *, @@ -4193,7 +4392,7 @@ def attach_items_from_integrated_storage( """Link images from integrated external storage to SuperAnnotate from AWS, GCP, Azure, Databricks. :param project: project name or folder path where items should be attached (e.g., “project1/folder1”). - :type project: str + :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] :param integration: The existing integration name or metadata dict to pull items from. Mandatory keys in integration metadata’s dict is “name”. @@ -4225,7 +4424,7 @@ def attach_items_from_integrated_storage( Request Example: :: - client.attach_items_from_integrated_storage( + sa_client.attach_items_from_integrated_storage( project="project_name", integration="databricks_integration", query="SELECT * FROM integration_data LIMIT 10", @@ -4238,7 +4437,7 @@ def attach_items_from_integrated_storage( ) """ - project, folder = self.controller.get_project_folder_by_path(project) + project, folder = self.controller.get_project_folder(project) _integration = None if isinstance(integration, str): integration = IntegrationEntity(name=integration) @@ -4267,10 +4466,12 @@ def query( project: NotEmptyStr | int | tuple[int, int] | tuple[str, str], query: NotEmptyStr | None = None, subset: NotEmptyStr | None = None, - ): + ) -> QueryResult: """Return items that satisfy the given query. Query syntax should be in SuperAnnotate query language(https://doc.superannotate.com/docs/explore-overview). + The returned QueryResult behaves like a list of dicts, and additionally exposes a .count() method. + :param project: Accepts a project as a string ("project" or "project/folder") or as a tuple (project_id, folder_id), where the folder is optional.” :type project: Union[str, int, Tuple[int, int], Tuple[str, str]] @@ -4282,14 +4483,54 @@ def query( :type subset: str :return: queried items' metadata list - :rtype: list of dicts + :rtype: QueryResult (list of dicts with .count() method) + + Request Example: + :: + + sa_client = SAClient() + + queried_items = sa_client.query( + project="Image Project", + query="metadata(lastAction.email = test@superannotate.com)" + ) + for item in queried_items: + print(item["name"]) + + .. py:method:: query.count() -> int + + Returns the total number of items matching the query. + + :return: total number of matching items + :rtype: int + + Request Example: + :: + + sa_client = SAClient() + + total = sa_client.query( + project="Image Project", + query="metadata(lastAction.email = test@superannotate.com)" + ).count() + print(f"Total matching items: {total}") """ project, folder = self.controller.get_project_folder(project) - items = self.controller.query_entities(project, folder, query, subset) - exclude = { - "meta", - } - return BaseSerializer.serialize_iterable(items, exclude=exclude) + fetch_entities = partial( + self.controller.query_entities, project, folder, query, subset + ) + return QueryResult( + data_fetcher=lambda: BaseSerializer.serialize_iterable( + fetch_entities(), exclude={"meta"} + ), + count_fetcher=partial( + self.controller.query_items_count, + project=project, + folder=folder, + query=query, + subset=subset, + ), + ) def get_item_metadata( self, @@ -4314,7 +4555,7 @@ def get_item_metadata( Request Example: :: - client.get_item_metadata( + sa_client.get_item_metadata( project="Medical Annotations", item_name = "image_1.png", include_custom_metadata=True @@ -4404,7 +4645,7 @@ def search_items( Request Example: :: - client.search_items( + sa_client.search_items( project="Medical Annotations", name_contains="image_1", include_custom_metadata=True @@ -4551,7 +4792,7 @@ def list_items( Request Example: :: - client.list_items( + sa_client.list_items( project="Medical Annotations", folder="folder1", include=["custom_metadata"], @@ -4581,7 +4822,7 @@ def list_items( Request Example with include categories: :: - client.list_items( + sa_client.list_items( project="My Multimodal", folder="folder1", include=["categories"] @@ -4615,7 +4856,7 @@ def list_items( Additional Filter Examples: :: - client.list_items( + sa_client.list_items( project="Medical Annotations", folder="folder2", annotation_status="Completed", @@ -4623,16 +4864,12 @@ def list_items( ) # Filter items assigned to a specific QA - client.list_items( + sa_client.list_items( project="Medical Annotations", assignee__user_id="qa@example.com" ) """ - project = ( - self.controller.get_project_by_id(project).data - if isinstance(project, int) - else self.controller.get_project(project) - ) + project = self.controller.get_project(project) if ( include and "categories" in include @@ -4738,7 +4975,7 @@ def list_projects( project_filters = { "custom_field__new single select custom field__contains": "text" } - client.list_projects(include=["custom_fields"], **project_filters) + sa_client.list_projects(include=["custom_fields"], **project_filters) :type filters: ProjectFilters, optional @@ -4748,7 +4985,7 @@ def list_projects( Request Example: :: - client.list_projects( + sa_client.list_projects( include=["custom_fields"], status__in=["InProgress", "Completed"], name__contains="Medical", @@ -4824,8 +5061,8 @@ def attach_items( Example: :: - client = SAClient() - client.attach_items( + sa_client = SAClient() + sa_client.attach_items( project="Medical Annotations", attachments=[{"name": "item", "url": "https://..."}] ) @@ -4833,8 +5070,8 @@ def attach_items( Example of attaching items from custom integration: :: - client = SAClient() - client.attach_items( + sa_client = SAClient() + sa_client.attach_items( project="Medical Annotations", attachments=[ { @@ -4955,8 +5192,8 @@ def generate_items( def copy_items( self, - source: NotEmptyStr | dict, - destination: NotEmptyStr | dict, + source: NotEmptyStr | int | tuple[int, int] | tuple[str, str], + destination: NotEmptyStr | int | tuple[int, int] | tuple[str, str], items: list[NotEmptyStr] | None = None, include_annotations: bool = True, duplicate_strategy: Literal[ @@ -4966,10 +5203,10 @@ def copy_items( """Copy items in bulk between folders in a project :param source: project name (root) or folder path to pick items from (e.g., “project1/folder1”). - :type source: str + :type source: Union[str, int, Tuple[int, int], Tuple[str, str]] :param destination: project name (root) or folder path to place copied items (e.g., “project1/folder2”). - :type destination: str + :type destination: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: names of items to copy. If None, all items from the source directory will be copied. :type items: list of str @@ -4998,17 +5235,16 @@ def copy_items( "Copy operation continuing without annotations and metadata due to include_annotations=False." ) - project_name, source_folder = extract_project_folder(source) - to_project_name, destination_folder = extract_project_folder(destination) - if project_name != to_project_name: + project, source_folder = self.controller.get_project_folder(source) + destination_project, destination_folder = self.controller.get_project_folder( + destination + ) + if project.name != destination_project.name: raise AppException("Source and destination projects should be the same") - project = self.controller.get_project(project_name) - from_folder = self.controller.get_folder(project, source_folder) - to_folder = self.controller.get_folder(project, destination_folder) response = self.controller.items.copy_multiple( project=project, - from_folder=from_folder, - to_folder=to_folder, + from_folder=source_folder, + to_folder=destination_folder, item_names=items, include_annotations=include_annotations, duplicate_strategy=duplicate_strategy, @@ -5020,8 +5256,8 @@ def copy_items( def move_items( self, - source: NotEmptyStr | dict, - destination: NotEmptyStr | dict, + source: NotEmptyStr | int | tuple[int, int] | tuple[str, str], + destination: NotEmptyStr | int | tuple[int, int] | tuple[str, str], items: list[NotEmptyStr] | None = None, duplicate_strategy: Literal[ "skip", "replace", "replace_annotations_only" @@ -5030,10 +5266,10 @@ def move_items( """Move items in bulk between folders in a project :param source: project name (root) or folder path to pick items from (e.g., “project1/folder1”). - :type source: str + :type source: Union[str, int, Tuple[int, int], Tuple[str, str]] :param destination: project name (root) or folder path to move items to (e.g., “project1/folder2”). - :type destination: str + :type destination: Union[str, int, Tuple[int, int], Tuple[str, str]] :param items: names of items to move. If None, all items from the source directory will be moved. :type items: list of str @@ -5052,17 +5288,16 @@ def move_items( :rtype: list of strs """ - project_name, source_folder = extract_project_folder(source) - to_project_name, destination_folder = extract_project_folder(destination) - if project_name != to_project_name: + project, folder = self.controller.get_project_folder(source) + destination_project, destination_folder = self.controller.get_project_folder( + destination + ) + if project.name != destination_project.name: raise AppException("Source and destination projects should be the same") - project = self.controller.get_project(project_name) - source_folder = self.controller.get_folder(project, source_folder) - destination_folder = self.controller.get_folder(project, destination_folder) response = self.controller.items.move_multiple( project=project, - from_folder=source_folder, + from_folder=folder, to_folder=destination_folder, item_names=items, duplicate_strategy=duplicate_strategy, @@ -5092,7 +5327,7 @@ def set_items_category( Request Example: :: - client.set_items_category( + sa_client.set_items_category( project=("product-review-mm", "folder1"), items=[112233, 112344], category="Shoes" @@ -5126,7 +5361,7 @@ def remove_items_category( Request Example: :: - client.remove_items_category( + sa_client.remove_items_category( project=("product-review-mm", "folder1"), items=[112233, 112344] ) @@ -5231,10 +5466,7 @@ def download_annotations( """ - project_name, folder_name = extract_project_folder(project) - project, folder = self.controller.get_project_folder( - (project_name, folder_name) - ) + project, folder = self.controller.get_project_folder(project) response = self.controller.annotations.download( project=project, folder=folder, @@ -5248,28 +5480,27 @@ def download_annotations( raise AppException(response.errors) return response.data - def get_subsets(self, project: NotEmptyStr | dict): + def get_subsets(self, project: NotEmptyStr | int): """Get Subsets - :param project: project name (e.g., “project1”) + :param project: project name (e.g., “project1”) or ID :type project: str :return: subsets’ metadata :rtype: list of dicts """ - project_name, _ = extract_project_folder(project) - project = self.controller.projects.get_by_name(project_name).data + project = self.controller.get_project(project) response = self.controller.subsets.list(project) if response.errors: raise AppException(response.errors) return BaseSerializer.serialize_iterable(response.data, ["name"]) - def create_custom_fields(self, project: NotEmptyStr, fields: dict): + def create_custom_fields(self, project: NotEmptyStr | int, fields: dict): """Create custom fields for items in a project in addition to built-in metadata. Using this function again with a different schema won't override the existing fields, but add new ones. Use the upload_custom_values() function to fill them with values for each item. - :param project: project name (e.g., “project1”) + :param project: project name (e.g., “project1”) or ID :type project: str :param fields: dictionary describing the fields and their specifications added to the project. @@ -5330,15 +5561,14 @@ def create_custom_fields(self, project: NotEmptyStr, fields: dict): } } - client = SAClient() - client.create_custom_fields( + sa_client = SAClient() + sa_client.create_custom_fields( project="Medical Annotations", fields=custom_fields ) """ - project_name, _ = extract_project_folder(project) - project = self.controller.projects.get_by_name(project_name).data + project = self.controller.get_project(project) response = self.controller.custom_fields.create_schema( project=project, schema=fields ) @@ -5346,10 +5576,10 @@ def create_custom_fields(self, project: NotEmptyStr, fields: dict): raise AppException(response.errors) return response.data - def get_custom_fields(self, project: NotEmptyStr): + def get_custom_fields(self, project: NotEmptyStr | int): """Get the schema of the custom fields defined for the project - :param project: project name (e.g., “project1”) + :param project: project name (e.g., “project1”) or ID :type project: str :return: custom fields actual schema of the project @@ -5385,19 +5615,20 @@ def get_custom_fields(self, project: NotEmptyStr): } } """ - project_name, _ = extract_project_folder(project) - project = self.controller.projects.get_by_name(project_name).data + project = self.controller.get_project(project) response = self.controller.custom_fields.get_schema(project=project) if response.errors: raise AppException(response.errors) return response.data def delete_custom_fields( - self, project: NotEmptyStr, fields: Annotated[list[str], Field(min_length=1)] + self, + project: NotEmptyStr | int, + fields: Annotated[list[str], Field(min_length=1)], ): """Remove custom fields from a project’s custom metadata schema. - :param project: project name (e.g., “project1”) + :param project: project name (e.g., “project1”) or ID :type project: str :param fields: list of field names to remove @@ -5409,8 +5640,8 @@ def delete_custom_fields( Request Example: :: - client = SAClient() - client.delete_custom_fields( + sa_client = SAClient() + sa_client.delete_custom_fields( project = "Medical Annotations", fields = ["duration", patient_age] ) @@ -5439,8 +5670,7 @@ def delete_custom_fields( } """ - project_name, _ = extract_project_folder(project) - project = self.controller.projects.get_by_name(project_name).data + project = self.controller.get_project(project) response = self.controller.custom_fields.delete_schema( project=project, fields=fields ) @@ -5472,7 +5702,7 @@ def upload_custom_values( Request Example: :: - client = SAClient() + sa_client = SAClient() items_values = [ { @@ -5501,7 +5731,7 @@ def upload_custom_values( } ] - client.upload_custom_values( + sa_client.upload_custom_values( project = "Medical Annotations", items = items_values ) @@ -5546,7 +5776,7 @@ def delete_custom_values( Request Example: :: - client.delete_custom_values( + sa_client.delete_custom_values( project = "Medical Annotations", items = [ {"image_1.png": ["study_date", "patient_sex"]}, @@ -5562,13 +5792,12 @@ def delete_custom_values( raise AppException(response.errors) def add_items_to_subset( - self, project: NotEmptyStr, subset: NotEmptyStr, items: list[dict] + self, project: NotEmptyStr | int, subset: NotEmptyStr, items: list[dict] ): """ - Associates selected items with a given subset. Non-existing subset will be automatically created. - :param project: project name (e.g., “project1”) + :param project: project name (e.g., “project1”) or ID :type project: str :param subset: a name of an existing/new subset to associate items with. @@ -5585,15 +5814,15 @@ def add_items_to_subset( Request Example: :: - client = SAClient() + sa_client = SAClient() # option 1 - queried_items = client.query( + queried_items = sa_client.query( project="Image Project", query="instance(error = true)" ) - client.add_items_to_subset( + sa_client.add_items_to_subset( project="Medical Annotations", subset="Brain Study - Disapproved", items=queried_items @@ -5610,7 +5839,7 @@ def add_items_to_subset( } ] - client.add_items_to_subset( + sa_client.add_items_to_subset( project="Image Project", subset="Subset Name", items=items_list @@ -5636,8 +5865,7 @@ def add_items_to_subset( } """ - project_name, _ = extract_project_folder(project) - project = self.controller.projects.get_by_name(project_name).data + project = self.controller.get_project(project) response = self.controller.subsets.add_items(project, subset, items) if response.errors: raise AppException(response.errors) @@ -5717,7 +5945,7 @@ def item_context( .. code-block:: python - with client.item_context("project_name/folder_name", "item_name") as item_context: + with sa_client.item_context("project_name/folder_name", "item_name") as item_context: metadata = item_context.get_metadata() value = item_context.get_component_value("prompts") item_context.set_component_value("prompts", value) @@ -5726,7 +5954,7 @@ def item_context( .. code-block:: python - with client.item_context(("project_name", "folder_name"), 12345) as context: + with sa_client.item_context(("project_name", "folder_name"), 12345) as context: metadata = context.get_metadata() print(metadata) @@ -5734,7 +5962,7 @@ def item_context( .. code-block:: python - with client.item_context((101, 202), "item_name") as context: + with sa_client.item_context((101, 202), "item_name") as context: value = context.get_component_value("component_id") print(value) @@ -5742,7 +5970,7 @@ def item_context( .. code-block:: python - with client.item_context("project_name/folder_name", "item_name", overwrite=True) as context: + with sa_client.item_context("project_name/folder_name", "item_name", overwrite=True) as context: context.set_component_value("component_id", "new_value") # No need to call .save(), changes are saved automatically on context exit. @@ -5753,21 +5981,12 @@ def item_context( from superannotate import FileChangedError try: - with client.item_context((101, 202), "item_name") as context: + with sa_client.item_context((101, 202), "item_name") as context: context.set_component_value("component_id", "new_value") except FileChangedError as e: print(f"An error occurred: {e}") """ - if isinstance(path, str): - project, folder = self.controller.get_project_folder_by_path(path) - elif len(path) == 2 and all([isinstance(i, str) for i in path]): - project = self.controller.get_project(path[0]) - folder = self.controller.get_folder(project, path[1]) - elif len(path) == 2 and all([isinstance(i, int) for i in path]): - project = self.controller.get_project_by_id(path[0]).data - folder = self.controller.get_folder_by_id(path[1], project.id).data - else: - raise AppException("Invalid path provided.") + project, folder = self.controller.get_project_folder(path) if project.type != ProjectType.MULTIMODAL: raise AppException( "This function is only supported for Multimodal projects." @@ -5802,7 +6021,7 @@ def list_workflows(self): Request Example: :: - client.list_workflows() + sa_client.list_workflows() Response Example: diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 4bfb72783..77f51b04c 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -28,8 +28,6 @@ LOG_FILE_LOCATION = f"{HOME_PATH}/logs" DEFAULT_LOGGING_LEVEL = "INFO" -_loggers = {} - def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): logger = logging.getLogger("sa") @@ -85,10 +83,6 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): MAX_IMAGE_SIZE = 100 * 1024 * 1024 # 100 MB limit TOKEN_UUID = "token" -ALREADY_EXISTING_FILES_WARNING = ( - "{} already existing file(s) found that won't be uploaded." -) - ATTACHING_FILES_MESSAGE = "Attaching {} file(s) to project {}." ATTACHING_UPLOAD_STATE_ERROR = "You cannot attach URLs in this type of project. Please attach it in an external storage project." @@ -189,6 +183,7 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): "ImageAutoAssignEnable", "TemplateState", "CategorizeItems", + "MaxIdleDuration", ] __alL__ = ( diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index a1beb230b..008f6ea62 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -12,11 +12,6 @@ from pydantic import PlainSerializer from pydantic_extra_types.color import Color -DATE_TIME_FORMAT_ERROR_MESSAGE = ( - "does not match expected format YYYY-MM-DDTHH:MM:SS.fffZ" -) -DATE_REGEX = r"\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d{3})Z" - _missing = object() diff --git a/src/superannotate/lib/core/entities/classes.py b/src/superannotate/lib/core/entities/classes.py index 8075589c9..ff9c440c0 100644 --- a/src/superannotate/lib/core/entities/classes.py +++ b/src/superannotate/lib/core/entities/classes.py @@ -11,11 +11,6 @@ from pydantic import StrictInt from pydantic import StrictStr -DATE_REGEX = r"\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d{3})Z" -DATE_TIME_FORMAT_ERROR_MESSAGE = ( - "does not match expected format YYYY-MM-DDTHH:MM:SS.fffZ" -) - class GroupTypeEnum(str, Enum): RADIO = "radio" diff --git a/src/superannotate/lib/core/entities/multimodal_form.py b/src/superannotate/lib/core/entities/multimodal_form.py index 72d797325..0ae732fd6 100644 --- a/src/superannotate/lib/core/entities/multimodal_form.py +++ b/src/superannotate/lib/core/entities/multimodal_form.py @@ -253,13 +253,6 @@ class FormModel(BaseModel): code: str | list | None = "" environments: list[Any] = [] - @property - def code_as_string(self) -> str: - """Convert code to string if it's a list""" - if isinstance(self.code, list): - return "\n".join(str(item) for item in self.code) - return self.code or "" - def _extract_all_components( self, components: list[dict[str, Any]] ) -> list[dict[str, Any]]: diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index a66c1fe07..eff5363c6 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -1,6 +1,5 @@ from __future__ import annotations -import datetime from enum import auto from enum import Enum from typing import Any @@ -51,13 +50,6 @@ def __repr__(self): return self._name_ -def _validate_string_date_wm(v: datetime.datetime) -> str: - """Convert datetime to string format for WM entities.""" - if isinstance(v, str): - return v - return v.strftime("%Y-%m-%dT%H:%M:%S+00:00") - - class WMProjectEntity(TimedBaseModel): model_config = ConfigDict(extra="ignore") @@ -98,6 +90,9 @@ class WMUserEntity(TimedBaseModel): email: str | None = None state: WMUserStateEnum | None = None custom_fields: dict | None = Field(default_factory=dict, alias="customField") + user_permissions: list[PermissionEntity] | None = Field( + default=None, alias="userPermissions" + ) @field_validator("custom_fields", mode="before") @classmethod @@ -123,6 +118,9 @@ class WMProjectUserEntity(TimedBaseModel): custom_fields: dict | None = Field(default_factory=dict, alias="customField") permissions: dict | None = None categories: list[dict] | None = None + user_permissions: list[PermissionEntity] | None = Field( + default=None, alias="userPermissions" + ) @field_validator("custom_fields", mode="before") @classmethod @@ -137,6 +135,22 @@ def model_dump_json(self, **kwargs): return super().model_dump_json(**kwargs) +class PermissionEntity(TimedBaseModel): + model_config = ConfigDict(extra="ignore") + + id: int | None = None + name: str | None = None + + +class PermissionGroupEntity(TimedBaseModel): + model_config = ConfigDict(extra="ignore") + + id: int | None = None + name: str | None = None + label: str | None = None + permissions: list[PermissionEntity] | None = Field(default_factory=list) + + class WMScoreEntity(TimedBaseModel): id: int team_id: int diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index b5f95a93a..d625411af 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -3,11 +3,11 @@ from typing import Any from lib.core import entities +from lib.core.entities.work_managament import PermissionGroupEntity from lib.core.entities.work_managament import TelemetryScoreEntity from lib.core.entities.work_managament import WMProjectEntity from lib.core.entities.work_managament import WMScoreEntity from lib.core.entities.work_managament import WMUserEntity -from lib.core.enums import ProjectType from lib.core.exceptions import AppException from pydantic import BaseModel from pydantic import ConfigDict @@ -166,10 +166,6 @@ class UserResponse(ServiceResponse): res_data: entities.UserEntity = None -class ModelListResponse(ServiceResponse): - res_data: list[entities.AnnotationClassEntity] = None - - class _IntegrationResponse(ServiceResponse): integrations: list[entities.IntegrationEntity] = Field(default_factory=list) @@ -190,10 +186,6 @@ class SubsetListResponse(ServiceResponse): res_data: list[entities.SubSetEntity] = None -class SubsetResponse(ServiceResponse): - res_data: entities.SubSetEntity = None - - class UploadAnnotationsResponse(ServiceResponse): res_data: UploadAnnotations | None = None @@ -210,10 +202,6 @@ class UserLimitsResponse(ServiceResponse): res_data: UserLimits = None -class ItemListResponse(ServiceResponse): - res_data: list[entities.BaseItemEntity] = None - - class FolderResponse(ServiceResponse): res_data: entities.FolderEntity = None @@ -270,13 +258,5 @@ class TelemetryScoreListResponse(ServiceResponse): res_data: list[TelemetryScoreEntity] = None -PROJECT_TYPE_RESPONSE_MAP = { - ProjectType.VECTOR: ImageResponse, - ProjectType.OTHER: ClassificationResponse, - ProjectType.VIDEO: VideoResponse, - ProjectType.TILED: TiledResponse, - ProjectType.PIXEL: ImageResponse, - ProjectType.DOCUMENT: DocumentResponse, - ProjectType.POINT_CLOUD: PointCloudResponse, - ProjectType.MULTIMODAL: ImageResponse, -} +class WMPermissionGroupListResponse(ServiceResponse): + res_data: list[PermissionGroupEntity] = None diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 6a28e04e4..54960f5aa 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -32,6 +32,7 @@ from lib.core.service_types import UserResponse from lib.core.service_types import WMClassesResponse from lib.core.service_types import WMCustomFieldResponse +from lib.core.service_types import WMPermissionGroupListResponse from lib.core.service_types import WMProjectListResponse from lib.core.service_types import WMScoreListResponse from lib.core.service_types import WMUserListResponse @@ -243,6 +244,21 @@ def set_remove_contributor_categories( ) -> list[dict]: raise NotImplementedError + @abstractmethod + def list_permission_groups(self) -> WMPermissionGroupListResponse: + raise NotImplementedError + + @abstractmethod + def edit_project_user_permissions( + self, + project_id: int, + contributor_ids: list[int], + permission_ids: list[int], + operation: Literal["grant", "revoke"], + chunk_size=100, + ) -> dict: + raise NotImplementedError + @abstractmethod def update_annotation_class( self, @@ -750,7 +766,9 @@ def saqul_query( def query_item_count( self, project: entities.ProjectEntity, + folder: entities.FolderEntity = None, query: str = None, + subset_id: int = None, ) -> ServiceResponse: raise NotImplementedError diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 4fea9b7ed..858013203 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -41,7 +41,6 @@ from lib.core.response import Response from lib.core.service_types import UploadAnnotationAuthData from lib.core.serviceproviders import BaseServiceProvider -from lib.core.serviceproviders import ServiceResponse from lib.core.serviceproviders import UploadAnnotationsResponse from lib.core.types import PriorityScoreEntity from lib.core.usecases.base import BaseReportableUseCase @@ -71,13 +70,6 @@ class Report: missing_attrs: list -def get_or_raise(response: ServiceResponse): - if response.ok: - return response.data - else: - raise AppException(response.error) - - def log_report( report: Report, ): @@ -245,7 +237,6 @@ async def _upload_big_annotation(item_data: ItemToUpload) -> tuple[str, bool]: class UploadAnnotationsUseCase(BaseReportableUseCase): CHUNK_SIZE = 500 - CHUNK_SIZE_MB = 10 * 1024 * 1024 URI_THRESHOLD = 4 * 1024 - 120 def __init__( @@ -452,10 +443,6 @@ class UploadAnnotationsFromFolderUseCase(BaseReportableUseCase): MAX_WORKERS = 16 CHUNK_SIZE = 100 CHUNK_SIZE_PATHS = 500 - CHUNK_SIZE_MB = 10 * 1024 * 1024 - STATUS_CHANGE_CHUNK_SIZE = 100 - AUTH_DATA_CHUNK_SIZE = 500 - THREADS_COUNT = 4 URI_THRESHOLD = 4 * 1024 - 120 def __init__( @@ -569,13 +556,6 @@ def prepare_annotation(self, annotation: dict, size) -> dict: ) return annotation - @staticmethod - def get_mask_path(path: str) -> str: - - replacement = ".json" - parts = path.rsplit(replacement, 1) - return constants.ANNOTATION_MASK_POSTFIX.join(parts) - async def get_annotation( self, path: str ) -> (tuple[io.StringIO] | None, io.BytesIO | None): @@ -1756,7 +1736,6 @@ def execute(self): class UploadMultiModalAnnotationsUseCase(BaseReportableUseCase): CHUNK_SIZE = 500 - CHUNK_SIZE_MB = 10 * 1024 * 1024 URI_THRESHOLD = 4 * 1024 - 120 def __init__( diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 5a1f2177e..2b9cf3b62 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -170,13 +170,17 @@ def __init__( self, reporter: Reporter, project: ProjectEntity, + folder: FolderEntity, service_provider: BaseServiceProvider, - query: str, + query: str | None, + subset: str | None = None, ): super().__init__(reporter) self._project = project + self._folder = folder self._service_provider = service_provider self._query = query + self._subset = subset def validate_arguments(self): if self._query: @@ -197,9 +201,40 @@ def validate_arguments(self): if not response.ok: raise AppException(response.error) + if not any([self._query, self._subset]): + raise AppException( + "The query and subset params cannot have the value None at the same time." + ) + if self._subset and not self._folder.is_root: + raise AppException( + "The folder name should be specified in the query string." + ) + def execute(self) -> Response: if self.is_valid(): - query_kwargs = {"query": self._query} + query_kwargs = {} + if self._subset: + response = self._service_provider.explore.list_subsets(self._project) + if response.ok: + subset = next( + (_sub for _sub in response.data if _sub.name == self._subset), + None, + ) + else: + self._response.errors = response.error + return self._response + if not subset: + self._response.errors = AppException( + "Subset not found. Use the superannotate." + "get_subsets() function to get a list of the available subsets." + ) + return self._response + query_kwargs["subset_id"] = subset.id + if self._query: + query_kwargs["query"] = self._query + query_kwargs["folder"] = ( + None if self._folder.name == "root" else self._folder + ) service_response = self._service_provider.explore.query_item_count( self._project, **query_kwargs, @@ -470,198 +505,6 @@ def execute(self) -> Response: return self._response -class CopyItems(BaseReportableUseCase): - """ - Copy items in bulk between folders in a project. - Return skipped item names. - """ - - CHUNK_SIZE = 500 - - def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - from_folder: FolderEntity, - to_folder: FolderEntity, - item_names: list[str], - service_provider: BaseServiceProvider, - include_annotations: bool, - ): - super().__init__(reporter) - self._project = project - self._from_folder = from_folder - self._to_folder = to_folder - self._item_names = item_names - self._service_provider = service_provider - self._include_annotations = include_annotations - - def _validate_limitations(self, items_count): - response = self._service_provider.get_limitations( - project=self._project, - folder=self._to_folder, - ) - if not response.ok: - raise AppValidationException(response.error) - if items_count > response.data.folder_limit.remaining_image_count: - raise AppValidationException(constants.COPY_FOLDER_LIMIT_ERROR_MESSAGE) - if items_count > response.data.project_limit.remaining_image_count: - raise AppValidationException(constants.COPY_PROJECT_LIMIT_ERROR_MESSAGE) - - def validate_item_names(self): - if self._item_names: - self._item_names = list(set(self._item_names)) - - def execute(self): - if self.is_valid(): - if self._item_names: - items = self._item_names - else: - data = self._service_provider.item_service.list( - self._project.id, self._from_folder.id, EmptyQuery() - ) - items = [i.name for i in data] - existing_items = [] - for i in range(0, len(items), self.CHUNK_SIZE): - query = Filter( - "name", items[i : i + self.CHUNK_SIZE], OperatorEnum.IN - ) # noqa - data = self._service_provider.item_service.list( - self._project.id, self._to_folder.id, query - ) - if not data: - continue - existing_items += data - duplications = [item.name for item in existing_items] - items_to_copy = list(set(items) - set(duplications)) - skipped_items = duplications - try: - self._validate_limitations(len(items_to_copy)) - except AppValidationException as e: - self._response.errors = e - return self._response - if items_to_copy: - for i in range(0, len(items_to_copy), self.CHUNK_SIZE): - chunk_to_copy = items_to_copy[i : i + self.CHUNK_SIZE] # noqa: E203 - response = self._service_provider.items.copy_multiple( - project=self._project, - from_folder=self._from_folder, - to_folder=self._to_folder, - item_names=chunk_to_copy, - include_annotations=self._include_annotations, - ) - if not response.ok or not response.data.get("poll_id"): - skipped_items.extend(chunk_to_copy) - continue - try: - self._service_provider.items.await_copy( - project=self._project, - poll_id=response.data["poll_id"], - items_count=len(chunk_to_copy), - ) - except BackendError as e: - self._response.errors = AppException(e) - return self._response - existing_items = [] - for i in range(0, len(items), self.CHUNK_SIZE): - data = self._service_provider.item_service.list( - self._project.id, - self._to_folder.id, - Filter( - "name", items[i : i + self.CHUNK_SIZE], OperatorEnum.IN - ), # noqa - ) - existing_items += data - - existing_item_names_set = {item.name for item in existing_items} - items_to_copy_names_set = set(items_to_copy) - copied_items = existing_item_names_set.intersection( - items_to_copy_names_set - ) - skipped_items.extend(list(items_to_copy_names_set - copied_items)) - self.reporter.log_info( - f"Copied {len(copied_items)}/{len(items)} item(s) from " - f"{self._project.name}{'' if self._from_folder.is_root else f'/{self._from_folder.name}'} to " - f"{self._project.name}{'' if self._to_folder.is_root else f'/{self._to_folder.name}'}" - ) - self._response.data = skipped_items - return self._response - - -class MoveItems(BaseReportableUseCase): - CHUNK_SIZE = 1000 - - def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - from_folder: FolderEntity, - to_folder: FolderEntity, - item_names: list[str], - service_provider: BaseServiceProvider, - ): - super().__init__(reporter) - self._project = project - self._from_folder = from_folder - self._to_folder = to_folder - self._item_names = item_names - self._service_provider = service_provider - - def validate_item_names(self): - if self._item_names: - self._item_names = list(set(self._item_names)) - - def _validate_limitations(self, items_count): - response = self._service_provider.get_limitations( - project=self._project, - folder=self._to_folder, - ) - if not response.ok: - raise AppValidationException(response.error) - if items_count > response.data.folder_limit.remaining_image_count: - raise AppValidationException(constants.MOVE_FOLDER_LIMIT_ERROR_MESSAGE) - if items_count > response.data.project_limit.remaining_image_count: - raise AppValidationException(constants.MOVE_PROJECT_LIMIT_ERROR_MESSAGE) - - def execute(self): - if self.is_valid(): - if not self._item_names: - items = [ - i.name - for i in self._service_provider.item_service.list( - self._project.id, self._from_folder.id, EmptyQuery() - ) - ] - else: - items = self._item_names - try: - self._validate_limitations(len(items)) - except AppValidationException as e: - self._response.errors = e - return self._response - moved_images = [] - for i in range(0, len(items), self.CHUNK_SIZE): - response = self._service_provider.items.move_multiple( - project=self._project, - from_folder=self._from_folder, - to_folder=self._to_folder, - item_names=items[i : i + self.CHUNK_SIZE], # noqa: E203 - ) - if not response.ok: - raise AppException(response.error) - if response.ok and response.data.get("done"): - moved_images.extend(response.data["done"]) - - self.reporter.log_info( - f"Moved {len(moved_images)}/{len(items)} item(s) from " - f"{self._project.name}{'' if self._from_folder.is_root else f'/{self._from_folder.name}'} to " - f"{self._project.name}{'' if self._to_folder.is_root else f'/{self._to_folder.name}'}" - ) - - self._response.data = list(set(items) - set(moved_images)) - return self._response - - class CopyMoveItems(BaseReportableUseCase): """ Copy/Move items in bulk between folders in a project. @@ -862,7 +705,7 @@ def execute(self): item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203, annotation_status=self._annotation_status_code, ) - if not status_changed: + if not status_changed.ok: self._response.errors = AppException(self.ERROR_MESSAGE) break return self._response diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 653357e60..0a165d877 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -103,7 +103,7 @@ def execute(self): None, ) if not project: - self._response.errors = AppException("Project not found") + self._response.errors = AppException("Project not found.") self._response.data = project return self._response diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index febb40bfd..c14cd69e5 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -513,6 +513,101 @@ def set_remove_contributor_categories( f"{len(contributor_ids)} contributors." ) + def edit_project_user_permissions( + self, + project: ProjectEntity, + user: int | str, + permissions: list[str] | Literal["*"], + operation: Literal["grant", "revoke"], + ): + if not permissions: + raise AppException("Permission(s) cannot be empty.") + + if isinstance(user, int): + project_users = self.list_users(project=project, id__in=[user]) + else: + project_users = self.list_users(project=project, email__in=[user]) + + if not project_users: + raise AppException("User not found.") + + name_by_id = self.service_provider.get_project_user_permission_id_name_map() + + if permissions == "*": + resolved_ids = list(name_by_id.keys()) + unresolved_names: list[str] = [] + else: + resolved_ids = [] + seen_ids: set = set() + unresolved_names = [] + for name in permissions: + pid = self.service_provider.get_project_user_permission_id(name) + if pid is None: + unresolved_names.append(name) + elif pid not in seen_ids: + resolved_ids.append(pid) + seen_ids.add(pid) + + project_user = project_users[0] + user_email = project_user.email + + affected_ids: set[int] = set() + if resolved_ids: + response = ( + self.service_provider.work_management.edit_project_user_permissions( + project_id=project.id, + contributor_ids=[project_user.id], + permission_ids=resolved_ids, + operation=operation, + ) + ) + section_key = "add" if operation == "grant" else "remove" + contributor_entry = next( + ( + c + for c in (response.get(section_key) or []) + if c.get("id") == project_user.id + ), + None, + ) + if contributor_entry: + affected_ids = { + p["id"] for p in (contributor_entry.get("userPermissions") or []) + } + + succeeded_names = [ + name_by_id[pid] for pid in resolved_ids if pid in affected_ids + ] + failed_names = [ + name_by_id[pid] for pid in resolved_ids if pid not in affected_ids + ] + unresolved_names + + verb_inf = "grant" if operation == "grant" else "revoke" + verb_past = "granted" if operation == "grant" else "revoked" + + if succeeded_names: + logger.info( + f"Successfully {verb_past} [{', '.join(succeeded_names)}] " + f"permission(s) for user: {user_email}." + ) + if failed_names: + failed_str = f"[{', '.join(failed_names)}]" + if operation == "grant": + reasons = ( + f"- User already has {failed_str} permission(s) granted.\n" + f"- User role does not allow {failed_str} permission(s).\n" + f"- Provided permission(s) were invalid." + ) + else: + reasons = ( + f"- {failed_str} permission(s) were already revoked for the user.\n" + f"- Provided permission(s) were invalid." + ) + logger.info( + f"Could not {verb_inf} {failed_str} permission(s) " + f"for user: {user_email}.\nPossible reasons:\n{reasons}" + ) + class ProjectManager(BaseManager): def __init__(self, service_provider: ServiceProvider, team: TeamEntity): @@ -943,26 +1038,6 @@ def _determine_condition_and_key(keys: list[str]) -> tuple[OperatorEnum, str]: condition = OperatorEnum.EQ return condition, ".".join(keys) - def _build_query( - self, project: ProjectEntity, filters: dict, include: list[str] - ) -> Query: - """Build the query object based on filters and include fields.""" - filter_annotations = ItemFilters.__annotations__.keys() - query = EmptyQuery() - _include = set(include if include else []) - for key, val in filters.items(): - if key in filter_annotations: - _keys = key.split("__") - entity = PROJECT_ITEM_ENTITY_MAP.get(project.type, BaseItemEntity) - if _keys[0] not in entity.__fields__: - _include.add(_keys[0]) - val = self._handle_special_fields(project, _keys, val) - condition, _key = self._determine_condition_and_key(_keys) - query &= Filter(_key, val, condition) - for i in _include: - query &= Join(i) - return query - @staticmethod def process_response( service_provider, @@ -1652,11 +1727,6 @@ def s3_repo(self): class Controller(BaseController): DEFAULT = None - @classmethod - def set_default(cls, obj): - cls.DEFAULT = obj - return cls.DEFAULT - def get_folder_by_id(self, folder_id: int, project_id: int): response = self.folders.get_by_id( folder_id=folder_id, project_id=project_id, team_id=self.team_id @@ -1683,8 +1753,15 @@ def get_project(self, name_or_id: int | str) -> ProjectEntity: raise AppException("Project not found.") return project - def get_folder(self, project: ProjectEntity, name: str = None) -> FolderEntity: - folder = self.folders.get_by_name(project, name).data + def get_folder( + self, project: ProjectEntity, name: str | int = None + ) -> FolderEntity: + if isinstance(name, int): + folder = self.folders.get_by_id( + folder_id=name, project_id=project.id, team_id=project.team_id + ).data + else: + folder = self.folders.get_by_name(project, name).data if not folder: raise AppException("Folder not found.") return folder @@ -1697,16 +1774,14 @@ def get_folder_name(name: str = None): def upload_image_to_project( self, - project_name: str, - folder_name: str, + project: ProjectEntity, + folder: FolderEntity, image_name: str, image: str | io.BytesIO = None, annotation_status: str = None, image_quality_in_editor: str = None, from_s3_bucket=None, ): - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) image_bytes = None image_path = None if isinstance(image, (str, Path)): @@ -1735,15 +1810,13 @@ def upload_image_to_project( def upload_images_to_project( self, - project_name: str, - folder_name: str, + project: ProjectEntity, + folder: FolderEntity, paths: list[str], annotation_status: str = None, image_quality_in_editor: str = None, from_s3_bucket=None, ): - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) return usecases.UploadImagesToProject( project=project, @@ -1761,7 +1834,7 @@ def upload_images_to_project( def upload_images_from_folder_to_project( self, project: ProjectEntity, - folder_name: str, + folder: FolderEntity, folder_path: str, extensions: list[str] | None = None, annotation_status: str = None, @@ -1770,7 +1843,6 @@ def upload_images_from_folder_to_project( image_quality_in_editor: str = None, from_s3_bucket=None, ): - folder = self.get_folder(project, folder_name) annotation_status_value = ( self.service_provider.get_annotation_status_value( project, annotation_status @@ -1794,7 +1866,7 @@ def upload_images_from_folder_to_project( def prepare_export( self, - project_name: str, + project: ProjectEntity, folder_names: list[str], include_fuse: bool, only_pinned: bool, @@ -1802,7 +1874,6 @@ def prepare_export( integration_id: int = None, export_type: int = None, ): - project = self.get_project(project_name) use_case = usecases.PrepareExportUseCase( project=project, folder_names=folder_names, @@ -1866,9 +1937,7 @@ def un_assign_folder(self, project_name: str, folder_name: str): ) return use_case.execute() - def get_exports(self, project_name: str, return_metadata: bool): - project = self.get_project(project_name) - + def get_exports(self, project: ProjectEntity, return_metadata: bool): use_case = usecases.GetExportsUseCase( service_provider=self.service_provider, project=project, @@ -1905,13 +1974,12 @@ def download_image( def download_export( self, - project_name: str, + project: ProjectEntity, export_name: str, folder_path: str, extract_zip_contents: bool, to_s3_bucket: bool, ): - project = self.get_project(project_name) use_case = usecases.DownloadExportUseCase( service_provider=self.service_provider, project=project, @@ -1964,8 +2032,8 @@ def invite_contributors_to_team(self, emails: list, set_admin: bool): def upload_videos( self, - project_name: str, - folder_name: str, + project: ProjectEntity, + folder: FolderEntity, paths: list[str], start_time: float, extensions: list[str] = None, @@ -1975,8 +2043,6 @@ def upload_videos( annotation_status: str | None = None, image_quality_in_editor: str | None = None, ): - project = self.get_project(project_name) - folder = self.get_folder(project, folder_name) annotation_status_value = ( self.service_provider.get_annotation_status_value( project, annotation_status @@ -2039,13 +2105,20 @@ def query_entities( self.service_provider, items, project, folder, map_fields=False ) - def query_items_count(self, project_name: str, query: str = None) -> int: - project = self.get_project(project_name) + def query_items_count( + self, + project: ProjectEntity, + folder: FolderEntity, + query: str | None = None, + subset: str | None = None, + ) -> int: use_case = usecases.QueryEntitiesCountUseCase( reporter=self.get_default_reporter(), project=project, + folder=folder, query=query, + subset=subset, service_provider=self.service_provider, ) response = use_case.execute() diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index ec2590aa0..3b1d7a386 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -37,7 +37,6 @@ class ServiceProvider(BaseServiceProvider): URL_USER = "user/ME" URL_USERS = "users" URL_GET_EXPORT = "export/{}" - URL_PREDICTION = "images/prediction" URL_FOLDERS_IMAGES = "images-folders" URL_INVITE_CONTRIBUTORS = "api/v1/team/{}/inviteUsers" URL_ANNOTATION_UPLOAD_PATH_TOKEN = "images/getAnnotationsPathsAndTokens" @@ -157,6 +156,16 @@ def get_annotation_status_name( project, status_value ) + def get_project_user_permission_id(self, name: str) -> int | None: + return self._cached_work_management_repository.get_project_user_permission_id( + self.client.team_id, name + ) + + def get_project_user_permission_id_name_map(self) -> dict[int, str]: + return self._cached_work_management_repository.get_project_user_permission_id_name_map( + self.client.team_id + ) + @staticmethod def _get_work_management_url(client: HttpClient): if client.api_url != constants.BACKEND_URL: diff --git a/src/superannotate/lib/infrastructure/services/annotation.py b/src/superannotate/lib/infrastructure/services/annotation.py index cc7ed0eb1..5fed8fb63 100644 --- a/src/superannotate/lib/infrastructure/services/annotation.py +++ b/src/superannotate/lib/infrastructure/services/annotation.py @@ -28,7 +28,6 @@ class AnnotationService(BaseAnnotationService): ASSETS_PROVIDER_VERSION = "v4" - DEFAULT_CHUNK_SIZE = 5000 URL_GET_ANNOTATIONS = "items/annotations/download" URL_UPLOAD_ANNOTATIONS = "items/annotations/upload" @@ -85,7 +84,7 @@ async def _sync_large_annotation( headers=self.client.default_headers, raise_for_status=True, ) as session: - _response = await session.request("post", sync_url, params=sync_params) + await session.request("post", sync_url, params=sync_params) sync_params.pop("current_source") sync_params.pop("desired_source") diff --git a/src/superannotate/lib/infrastructure/services/explore.py b/src/superannotate/lib/infrastructure/services/explore.py index 4afbd544a..341fae491 100644 --- a/src/superannotate/lib/infrastructure/services/explore.py +++ b/src/superannotate/lib/infrastructure/services/explore.py @@ -196,13 +196,19 @@ def saqul_query( def query_item_count( self, project: entities.ProjectEntity, + folder: entities.FolderEntity = None, query: str = None, + subset_id: int = None, ) -> ServiceResponse: params = { "project_id": project.id, "includeFolderNames": True, } + if folder: + params["folder_id"] = folder.id + if subset_id: + params["subset_id"] = subset_id data = {"query": query} response = self.client.request( urljoin(self.explore_service_url, self.URL_QUERY_COUNT), diff --git a/src/superannotate/lib/infrastructure/services/item_service.py b/src/superannotate/lib/infrastructure/services/item_service.py index e8d529362..0528bd2fb 100644 --- a/src/superannotate/lib/infrastructure/services/item_service.py +++ b/src/superannotate/lib/infrastructure/services/item_service.py @@ -9,7 +9,6 @@ class ItemService(SuperannotateServiceProvider): - MAX_URI_LENGTH = 15_000 URL_LIST = "items/search" URL_GET = "items/{item_id}" diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index e5b32a417..0a4d2b7d7 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -8,6 +8,7 @@ from lib.core.entities import FolderEntity from lib.core.entities import WorkflowEntity from lib.core.entities.project_entities import BaseEntity +from lib.core.entities.work_managament import PermissionGroupEntity from lib.core.entities.work_managament import WMAnnotationClassEntity from lib.core.entities.work_managament import WMProjectEntity from lib.core.entities.work_managament import WMProjectUserEntity @@ -24,6 +25,7 @@ from lib.core.service_types import ServiceResponse from lib.core.service_types import WMClassesResponse from lib.core.service_types import WMCustomFieldResponse +from lib.core.service_types import WMPermissionGroupListResponse from lib.core.service_types import WMProjectListResponse from lib.core.service_types import WMScoreListResponse from lib.core.service_types import WMUserListResponse @@ -76,6 +78,8 @@ class WorkManagementService(BaseWorkManagementService): URL_SEARCH_PROJECTS = "projects/search" URL_RESUME_PAUSE_USER = "teams/editprojectsusers" URL_CONTRIBUTORS_CATEGORIES = "customentities/edit" + URL_EDIT_PROJECT_USER_PERMISSIONS = "customentities/edit" + URL_PERMISSION_GROUPS = "permissiongroups" URL_UPDATE_ANNOTATION_CLASS = "classes/{class_id}" @staticmethod @@ -539,6 +543,62 @@ def set_remove_contributor_categories( return success_contributors + def list_permission_groups(self) -> WMPermissionGroupListResponse: + return self.client.paginate( + url=self.URL_PERMISSION_GROUPS, + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id + ), + }, + item_type=PermissionGroupEntity, + ) + + def edit_project_user_permissions( + self, + project_id: int, + contributor_ids: list[int], + permission_ids: list[int], + operation: Literal["grant", "revoke"], + chunk_size=100, + ) -> dict: + from lib.infrastructure.utils import divide_to_chunks + + params = { + "entity": CustomFieldEntityEnum.CONTRIBUTOR.value, + "parentEntity": CustomFieldEntityEnum.PROJECT.value, + "action": "editpermissions", + } + op_key = "add" if operation == "grant" else "remove" + + affected: dict = {"add": [], "remove": []} + + 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_EDIT_PROJECT_USER_PERMISSIONS, + method="post", + params=params, + data={ + **body_query.body_builder(), + "body": { + op_key: {"userPermissions": [{"id": i} for i in permission_ids]} + }, + }, + headers={ + "x-sa-entity-context": self._generate_context( + team_id=self.client.team_id, project_id=project_id + ), + }, + ) + response.raise_for_status() + data = response.data.get("data") or {} + affected["add"].extend(data.get("add") or []) + affected["remove"].extend(data.get("remove") or []) + + return affected + def update_annotation_class( self, project_id: int, diff --git a/src/superannotate/lib/infrastructure/stream_data_handler.py b/src/superannotate/lib/infrastructure/stream_data_handler.py index 9dcd90d40..27a25c1a1 100644 --- a/src/superannotate/lib/infrastructure/stream_data_handler.py +++ b/src/superannotate/lib/infrastructure/stream_data_handler.py @@ -42,13 +42,6 @@ def __init__( self._items_downloaded = 0 self._active_sessions = set() - def get_json(self, data: bytes): - try: - return json.loads(data) - except json.decoder.JSONDecodeError as e: - self._reporter.log_error(f"Invalud chunk: {str(e)}") - return None - @async_retry_on_generator((BackendError,)) async def fetch( self, diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index f682662d4..490cb767b 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -274,6 +274,44 @@ def get(self, key, **kwargs): return self._K_V_map[key] +class ProjectUserPermissionCache(BaseCachedWorkManagementRepository): + DEFAULT_TTL_SECONDS = 600 + + def __init__(self, work_management: WorkManagementService): + super().__init__(self.DEFAULT_TTL_SECONDS, work_management) + + def sync(self, team_id): + response = self.work_management.list_permission_groups() + if not response.ok: + raise AppException(response.error) + project_user_permissions = [ + perm + for group in (response.data or []) + if group.label == "projectUser" + for perm in (group.permissions or []) + ] + id_name_map = { + p.id: p.name + for p in project_user_permissions + if p.id is not None and p.name + } + name_id_lower_map = { + p.name.lower(): p.id + for p in project_user_permissions + if p.id is not None and p.name + } + self._K_V_map[team_id] = { + "id_name_map": id_name_map, + "name_id_lower_map": name_id_lower_map, + } + self._update_cache_timestamp(team_id) + + def get(self, key, **kwargs): + if not self._is_cache_valid(key): + self.sync(team_id=key) + return self._K_V_map[key] + + class ProjectUserCustomFieldCache(CustomFieldCache): def sync(self, project_id): response = self.work_management.list_custom_field_templates( @@ -329,6 +367,17 @@ def __init__(self, ttl_seconds: int, work_management): CustomFieldEntityEnum.CONTRIBUTOR, CustomFieldEntityEnum.PROJECT, ) + self._project_user_permission_cache = ProjectUserPermissionCache( + work_management + ) + + def get_project_user_permission_id(self, team_id: int, name: str) -> int | None: + data = self._project_user_permission_cache.get(team_id) + return data["name_id_lower_map"].get(name.lower()) + + def get_project_user_permission_id_name_map(self, team_id: int) -> dict[int, str]: + data = self._project_user_permission_cache.get(team_id) + return dict(data["id_name_map"]) def get_category_id(self, project, category_name: str) -> int: data = self._category_cache.get(project.id, project=project) diff --git a/src/superannotate/lib/infrastructure/validators.py b/src/superannotate/lib/infrastructure/validators.py index a96b66f1d..04d535829 100644 --- a/src/superannotate/lib/infrastructure/validators.py +++ b/src/superannotate/lib/infrastructure/validators.py @@ -40,26 +40,6 @@ def all_literal_values(type_: typing.Any) -> tuple[typing.Any, ...]: return () -def make_literal_validator( - type_: typing.Any, -) -> typing.Callable[[typing.Any], typing.Any]: - """ - Adding ability to input literal in the lower case. - """ - permitted_choices = all_literal_values(type_) - allowed_choices = { - v.lower() if isinstance(v, str) and v else v: v for v in permitted_choices - } - - def literal_validator(v: typing.Any) -> typing.Any: - try: - return allowed_choices[v.lower() if isinstance(v, str) else v] - except (KeyError, AttributeError): - raise WrongConstantError(given=v, permitted=permitted_choices) - - return literal_validator - - def get_tabulation() -> int: try: return int(os.get_terminal_size().columns / 2) diff --git a/tests/integration/base.py b/tests/integration/base.py index 2b7a8344c..f66cfa8ed 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -26,7 +26,7 @@ def tearDown(self) -> None: projects = sa.list_projects(name=self.PROJECT_NAME) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project["id"]) except Exception as e: print(str(e)) except Exception as e: diff --git a/tests/integration/classes/test_create_annotation_classes_from_classes_json.py b/tests/integration/classes/test_create_annotation_classes_from_classes_json.py index e918bdd7c..cbb2f27a0 100644 --- a/tests/integration/classes/test_create_annotation_classes_from_classes_json.py +++ b/tests/integration/classes/test_create_annotation_classes_from_classes_json.py @@ -48,6 +48,31 @@ def test_create_annotation_class_from_json(self): ) self.assertEqual(len(sa.search_annotation_classes(self.PROJECT_NAME)), 4) + def test_create_annotation_class_from_json_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.create_annotation_classes_from_classes_json(project_id, self.classes_json) + self.assertEqual(len(sa.search_annotation_classes(project_id)), 4) + + def test_download_annotation_classes_json_by_project_id(self): + sa.create_annotation_classes_from_classes_json( + self.PROJECT_NAME, self.classes_json + ) + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + with tempfile.TemporaryDirectory() as tmpdir_name: + path = sa.download_annotation_classes_json(project_id, tmpdir_name) + assert os.path.isfile(path) + + def test_delete_annotation_class_by_project_id(self): + sa.create_annotation_classes_from_classes_json( + self.PROJECT_NAME, self.classes_json + ) + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + classes = sa.search_annotation_classes(project_id) + assert classes + sa.delete_annotation_class(project_id, classes[0]["name"]) + remaining = sa.search_annotation_classes(project_id) + assert len(remaining) == len(classes) - 1 + def test_invalid_json(self): try: sa.create_annotation_classes_from_classes_json( diff --git a/tests/integration/custom_fields/test_custom_schema.py b/tests/integration/custom_fields/test_custom_schema.py index 539b0b208..fb06765da 100644 --- a/tests/integration/custom_fields/test_custom_schema.py +++ b/tests/integration/custom_fields/test_custom_schema.py @@ -73,6 +73,17 @@ def test_delete_schema(self): assert response == payload self.assertEqual(sa.get_custom_fields(self.PROJECT_NAME), payload) + def test_create_get_delete_schema_by_project_id(self): + payload = copy.copy(self.PAYLOAD) + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.create_custom_fields(project_id, payload) + self.assertEqual(sa.get_custom_fields(project_id), payload) + to_delete_field = list(payload.keys())[0] + response = sa.delete_custom_fields(project_id, [to_delete_field]) + del payload[to_delete_field] + assert response == payload + self.assertEqual(sa.get_custom_fields(project_id), payload) + def test_upload_delete_custom_values_query(self): sa.create_custom_fields(self.PROJECT_NAME, self.PAYLOAD) item_name = "test" diff --git a/tests/integration/export/test_export.py b/tests/integration/export/test_export.py index 5b0d8cd47..b97c844e7 100644 --- a/tests/integration/export/test_export.py +++ b/tests/integration/export/test_export.py @@ -125,6 +125,23 @@ def test_export_with_statuses(self): assert not filecmp.dircmp(tmpdir_name, self.TEST_FOLDER_PATH).left_only assert not filecmp.dircmp(tmpdir_name, self.TEST_FOLDER_PATH).right_only + def test_export_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + with tempfile.TemporaryDirectory() as tmpdir_name: + export = sa.prepare_export(project_id, include_fuse=True) + sa.download_export(project_id, export, tmpdir_name) + assert not filecmp.dircmp(tmpdir_name, self.TEST_FOLDER_PATH).left_only + assert not filecmp.dircmp(tmpdir_name, self.TEST_FOLDER_PATH).right_only + + def test_get_exports_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.prepare_export(self.PROJECT_NAME) + exports_by_name = sa.get_exports(self.PROJECT_NAME, return_metadata=True) + exports_by_id = sa.get_exports(project_id, return_metadata=True) + assert {e["name"] for e in exports_by_id} == { + e["name"] for e in exports_by_name + } + class TestDeleteExports(BaseTestCase): PROJECT_NAME = "TestDeleteExports" diff --git a/tests/integration/folders/test_create_folder.py b/tests/integration/folders/test_create_folder.py index 68952c79b..5ee8f2707 100644 --- a/tests/integration/folders/test_create_folder.py +++ b/tests/integration/folders/test_create_folder.py @@ -29,3 +29,9 @@ def test_create_folder_with_special_chars(self): self.assertIsNotNone(folder) assert "completedCount" not in folder.keys() assert "is_root" not in folder.keys() + + def test_create_folder_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.create_folder(project_id, self.TEST_FOLDER_NAME) + folder = sa.get_folder_metadata(self.PROJECT_NAME, self.TEST_FOLDER_NAME) + self.assertEqual(folder["name"], self.TEST_FOLDER_NAME) diff --git a/tests/integration/folders/test_delete_folders.py b/tests/integration/folders/test_delete_folders.py index edd85d131..ab605b9c5 100644 --- a/tests/integration/folders/test_delete_folders.py +++ b/tests/integration/folders/test_delete_folders.py @@ -41,3 +41,12 @@ def test_search_folders(self): sa.delete_folders(self.PROJECT_NAME, folder_names) folders = sa.search_folders(self.PROJECT_NAME) assert len(folders) == 0 + + def test_delete_folders_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.create_folder(self.PROJECT_NAME, self.TEST_FOLDER_NAME_1) + sa.create_folder(self.PROJECT_NAME, self.TEST_FOLDER_NAME_2) + sa.delete_folders(project_id, folder_names=[self.TEST_FOLDER_NAME_1]) + folders = sa.search_folders(self.PROJECT_NAME) + assert self.TEST_FOLDER_NAME_1 not in folders + assert self.TEST_FOLDER_NAME_2 in folders diff --git a/tests/integration/folders/test_get_folder_metadata.py b/tests/integration/folders/test_get_folder_metadata.py index 6d7af552c..376de7e35 100644 --- a/tests/integration/folders/test_get_folder_metadata.py +++ b/tests/integration/folders/test_get_folder_metadata.py @@ -35,3 +35,10 @@ def test_get_folder_metadata(self): with self.assertRaises(AppException) as cm: sa.get_folder_metadata(self.PROJECT_NAME, "dummy folder") assert str(cm.exception) == "Folder not found." + + def test_get_folder_metadata_by_project_id(self): + sa.create_folder(self.PROJECT_NAME, self.TEST_FOLDER_NAME) + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + folder_metadata = sa.get_folder_metadata(project_id, self.TEST_FOLDER_NAME) + assert folder_metadata["name"] == self.TEST_FOLDER_NAME + assert "is_root" not in folder_metadata diff --git a/tests/integration/folders/test_search_folders.py b/tests/integration/folders/test_search_folders.py index c368d8775..9e132516e 100644 --- a/tests/integration/folders/test_search_folders.py +++ b/tests/integration/folders/test_search_folders.py @@ -1,3 +1,5 @@ +import warnings + from src.superannotate import AppException from src.superannotate import SAClient from tests.integration.base import BaseTestCase @@ -57,3 +59,11 @@ def test_search_folders(self): ) with self.assertRaisesRegex(AppException, pattern): folders = sa.search_folders(self.PROJECT_NAME, status="dummy") # noqa + + def test_search_folders_deprecation_warning(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sa.search_folders(self.PROJECT_NAME) + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert any("search_folders" in str(w.message) for w in deprecations) + assert any("list_folders" in str(w.message) for w in deprecations) diff --git a/tests/integration/folders/test_set_folder_status.py b/tests/integration/folders/test_set_folder_status.py index 5c1b78fc5..3c81d0986 100644 --- a/tests/integration/folders/test_set_folder_status.py +++ b/tests/integration/folders/test_set_folder_status.py @@ -86,3 +86,14 @@ def test_set_folder_status_via_invalid_folder(self): sa.set_folder_status( project=self.PROJECT_NAME, folder="Invalid Name", status="Completed" ) + + def test_set_folder_status_by_project_and_folder_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + folder_id = sa.get_folder_metadata( + project=self.PROJECT_NAME, folder_name=self.FOLDER_NAME + )["id"] + sa.set_folder_status(project=project_id, folder=folder_id, status="Completed") + folder = sa.get_folder_metadata( + project=self.PROJECT_NAME, folder_name=self.FOLDER_NAME + ) + self.assertEqual(folder["status"], "Completed") diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 454e48797..7bcd7eded 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -1,8 +1,12 @@ import json import os from pathlib import Path +from unittest import TestCase +from unittest.mock import MagicMock +from unittest.mock import patch from src.superannotate import FileChangedError +from src.superannotate import ItemContext from src.superannotate import SAClient from tests.integration.base import BaseTestCase @@ -135,3 +139,63 @@ def tearDown(self) -> None: sa.delete_project(self.PROJECT_NAME) except Exception: ... + + +class TestItemContextSetComponentCalledFlag(TestCase): + def _make_context(self): + ic = ItemContext( + controller=MagicMock(), + project=MagicMock(), + folder=MagicMock(), + item=MagicMock(), + overwrite=True, + ) + ic._annotation_adapter = MagicMock() + ic._annotation_adapter.annotation = {"metadata": {}, "data": {}} + return ic + + def test_dirty_flag_initial_state(self): + ic = self._make_context() + self.assertFalse(ic._set_component_called) + + def test_set_component_value_marks_dirty(self): + ic = self._make_context() + ic.set_component_value("component_id", "value") + self.assertTrue(ic._set_component_called) + + def test_save_called_on_exit_after_set_component_value(self): + ic = self._make_context() + with patch.object(ItemContext, "save", autospec=True) as save_mock: + with ic: + ic.set_component_value("component_id", "value") + save_mock.assert_called_once_with(ic) + + def test_dirty_flag_reset_after_save(self): + ic = self._make_context() + with patch.object(ic, "_set_small_annotation_adapter"), patch.object( + ic, "_set_large_annotation_adapter" + ): + ic.set_component_value("component_id", "value") + self.assertTrue(ic._set_component_called) + ic.save() + self.assertFalse(ic._set_component_called) + + def test_no_double_save_on_exit_after_manual_save(self): + ic = self._make_context() + with patch.object(ic, "_set_small_annotation_adapter"), patch.object( + ic, "_set_large_annotation_adapter" + ): + with ic: + ic.set_component_value("component_id", "value") + ic.save() + self.assertEqual(ic._annotation_adapter.save.call_count, 1) + self.assertEqual(ic._annotation_adapter.save.call_count, 1) + + def test_save_not_called_when_exception_raised(self): + ic = self._make_context() + with patch.object(ItemContext, "save", autospec=True) as save_mock: + with self.assertRaises(RuntimeError): + with ic: + ic.set_component_value("component_id", "value") + raise RuntimeError("boom") + save_mock.assert_not_called() diff --git a/tests/integration/items/test_saqul_query.py b/tests/integration/items/test_saqul_query.py index 1e964c3ba..4a2a2412c 100644 --- a/tests/integration/items/test_saqul_query.py +++ b/tests/integration/items/test_saqul_query.py @@ -59,13 +59,51 @@ def test_query_on_100(self): sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) entities = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") assert len(entities) == 100 - assert ( - sa.controller.query_items_count( - self.PROJECT_NAME, "metadata(status = NotStarted)" - ) - == 100 + assert entities.count() == len(entities) + + def test_query_result_list_like_behavior(self): + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + + self.assertEqual(len(result), 100) + self.assertIsInstance(result[0], dict) + self.assertIn("name", result[0]) + self.assertIsInstance(result[-1], dict) + self.assertEqual(len(result[0:5]), 5) + + items = [item for item in result] + self.assertEqual(len(items), 100) + self.assertIsInstance(list(result), list) + + def test_query_result_lazy_count(self): + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + + self.assertFalse(result._loaded) + self.assertEqual(result.count(), 100) + self.assertFalse(result._loaded) + + _ = result[0] + self.assertTrue(result._loaded) + + def test_query_result_count_respects_subset(self): + subset_name = "subset_a" + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + all_items = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + subset_items = [ + {"name": item["name"], "path": self.PROJECT_NAME} for item in all_items[:30] + ] + sa.add_items_to_subset(self.PROJECT_NAME, subset_name, subset_items) + + result = sa.query( + self.PROJECT_NAME, + "metadata(status = NotStarted)", + subset=subset_name, ) + self.assertEqual(result.count(), len(subset_items)) + self.assertEqual(result.count(), len(list(result))) + def test_validate_saqul_query(self): try: self.assertRaises( diff --git a/tests/integration/projects/test_clone_project.py b/tests/integration/projects/test_clone_project.py index e186ce2fa..035c6b404 100644 --- a/tests/integration/projects/test_clone_project.py +++ b/tests/integration/projects/test_clone_project.py @@ -196,6 +196,21 @@ def test_clone_video_project(self): self.assertEqual(new_project["type"].lower(), "video") self.assertEqual(new_project["description"], self._project_1["description"]) + def test_clone_video_project_from_project_id(self): + self._project_1 = sa.create_project( + self.PROJECT_NAME_1, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + ) + from_project_id = self._project_1["id"] + new_project = sa.clone_project( + project_name=self.PROJECT_NAME_2, + from_project=from_project_id, + ) + self.assertEqual(new_project["name"], self.PROJECT_NAME_2) + self.assertEqual(new_project["type"].lower(), "video") + self.assertEqual(new_project["description"], self._project_1["description"]) + def test_clone_video_project_frame_mode_on(self): self._project_1 = sa.create_project( self.PROJECT_NAME_1, diff --git a/tests/integration/projects/test_get_project_metadata.py b/tests/integration/projects/test_get_project_metadata.py index ae22cc95e..43f7b80ff 100644 --- a/tests/integration/projects/test_get_project_metadata.py +++ b/tests/integration/projects/test_get_project_metadata.py @@ -86,3 +86,10 @@ def test_get_project_by_id(self): project_metadata = sa.get_project_metadata(self.PROJECT_NAME) project_by_id = sa.get_project_by_id(project_metadata["id"]) assert project_by_id["name"] == self.PROJECT_NAME + + def test_get_project_metadata_by_id(self): + by_name = sa.get_project_metadata(self.PROJECT_NAME) + by_id = sa.get_project_metadata(by_name["id"]) + assert by_id["name"] == self.PROJECT_NAME + assert by_id["id"] == by_name["id"] + assert by_id["type"] == self.PROJECT_TYPE diff --git a/tests/integration/projects/test_project_rename.py b/tests/integration/projects/test_project_rename.py index c18ec64e5..d63eebede 100644 --- a/tests/integration/projects/test_project_rename.py +++ b/tests/integration/projects/test_project_rename.py @@ -47,3 +47,10 @@ def test_rename_with_substring_of_an_existing_name(self): metadata = sa.get_project_metadata(self.NAME_TO_RENAME) self.assertEqual(self.NAME_TO_RENAME, metadata["name"]) sa.delete_project(self.NAME_TO_RENAME) + + def test_rename_project_by_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.rename_project(project_id, self.NEW_PROJECT_NAME) + meta = sa.get_project_metadata(self.NEW_PROJECT_NAME) + self.assertEqual(meta["name"], self.NEW_PROJECT_NAME) + self.assertEqual(meta["id"], project_id) diff --git a/tests/integration/projects/test_set_project_status.py b/tests/integration/projects/test_set_project_status.py index 77b1260ec..042eefc90 100644 --- a/tests/integration/projects/test_set_project_status.py +++ b/tests/integration/projects/test_set_project_status.py @@ -61,3 +61,9 @@ def test_set_project_status_via_invalid_project(self): "Project not found.", ): sa.set_project_status(project="Invalid name", status="Completed") + + def test_set_project_status_by_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + sa.set_project_status(project=project_id, status="Completed") + project = sa.get_project_metadata(self.PROJECT_NAME) + self.assertEqual(project["status"], "Completed") diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index 7aaa1d4f3..3ce9feb0d 100644 --- a/tests/integration/settings/test_settings.py +++ b/tests/integration/settings/test_settings.py @@ -155,3 +155,41 @@ def test_frames_reset(self): break else: raise Exception("Test failed") + + +class TestMMSettings(BaseTestCase): + """ + Require telemetry access + """ + + PROJECT_NAME = "TestMMSettings" + SECOND_PROJECT_NAME = "TestMMSettings" + PROJECT_TYPE = "Multimodal" + MULTIMODAL_FORM = { + "components": [ + { + "id": "r_qx07c6", + "type": "audio", + "permissions": [], + "hasTooltip": False, + "exclude": False, + "label": "", + "value": "", + } + ], + "readme": "", + } + + def test_frame_rate(self): + sa.create_project( + self.PROJECT_NAME, + self.PROJECT_DESCRIPTION, + self.PROJECT_TYPE, + settings=[{"attribute": "MaxIdleDuration", "value": 612}], + form=self.MULTIMODAL_FORM, + ) + settings = sa.get_project_settings(self.PROJECT_NAME) + for setting in settings: + if setting["attribute"] == "MaxIdleDuration": + assert setting["value"] == 612 + break diff --git a/tests/integration/subsets/test_subsets.py b/tests/integration/subsets/test_subsets.py index 088f36179..b92d7f70c 100644 --- a/tests/integration/subsets/test_subsets.py +++ b/tests/integration/subsets/test_subsets.py @@ -40,3 +40,24 @@ def test_add_to_subset_with_duplicates_items(self): assert ( "INFO:sa:Dropping duplicates. Found 1 / 2 unique items." == cm.output[2] ) + + def test_add_items_to_subset_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + item_names = [ + {"name": f"earth_mov_00{i}.jpg", "url": f"url_{i}"} for i in range(1, 4) + ] + sa.attach_items(self.PROJECT_NAME, item_names) + subset_data = [ + {"name": i["name"], "path": self.PROJECT_NAME} for i in item_names + ] + result = sa.add_items_to_subset(project_id, self.SUBSET_NAME, subset_data) + assert len(subset_data) == len(result["succeeded"]) + + def test_get_subsets_by_project_id(self): + project_id = sa.get_project_metadata(self.PROJECT_NAME)["id"] + item_names = [{"name": "earth_mov_001.jpg", "url": "url_1"}] + sa.attach_items(self.PROJECT_NAME, item_names) + subset_data = [{"name": "earth_mov_001.jpg", "path": self.PROJECT_NAME}] + sa.add_items_to_subset(self.PROJECT_NAME, self.SUBSET_NAME, subset_data) + subsets = sa.get_subsets(project_id) + assert self.SUBSET_NAME in [s["name"] for s in subsets] diff --git a/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py index ff013d485..a624e914a 100644 --- a/tests/integration/work_management/test_list_users.py +++ b/tests/integration/work_management/test_list_users.py @@ -1,3 +1,5 @@ +import contextlib + import pytest from superannotate import SAClient from tests.integration.base import BaseTestCase @@ -7,10 +9,12 @@ class TestListUsers(BaseTestCase): PROJECT_NAME = "TestListUsers" + PROJECT_NAME_2 = "TestListUsersProjectPermissions" PROJECT_TYPE = "Vector" def setUp(self): super().setUp() + sa.create_project(self.PROJECT_NAME_2, "desc", self.PROJECT_TYPE) team_users = sa.list_users() assert len(team_users) > 0 scapegoat = [ @@ -23,6 +27,11 @@ def setUp(self): self.PROJECT_NAME, [scapegoat["email"]], "Annotator" ) + def tearDown(self) -> None: + super().tearDown() + with contextlib.suppress(Exception): + sa.delete_project(self.PROJECT_NAME_2) + @pytest.mark.skip(reason="For not send real email") def test_pending_users(self): test_email = "test1@superannotate.com" @@ -57,3 +66,64 @@ def test_list_users_by_project_ID(self): user_1 = project_users[0] assert user_1["role"] == "Annotator" assert user_1["email"] == self.scapegoat["email"] + + def test_list_users_user_permissions_project_level(self): + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + ) + assert len(project_users) == 1 + user = project_users[0] + assert "user_permissions" in user + assert isinstance(user["user_permissions"], list) + + def test_list_users_user_permissions_team_level(self): + team_users = sa.list_users(email=self.scapegoat["email"]) + assert len(team_users) == 1 + user = team_users[0] + assert "user_permissions" in user + assert isinstance(user["user_permissions"], list) + + def test_list_users_user_permissions_team_level_all(self): + team_users = sa.list_users() + assert len(team_users) > 0 + for user in team_users: + assert "user_permissions" in user + assert isinstance(user["user_permissions"], list) + + def test_list_users_user_permissions_after_grant_project_level(self): + permission = "Download" + sa.add_contributors_to_project( + self.PROJECT_NAME_2, [self.scapegoat["email"]], "ProjectAdmin" + ) + sa.grant_project_user_permissions( + project=self.PROJECT_NAME_2, + permissions=[permission], + user=self.scapegoat["email"], + ) + project_users = sa.list_users( + project=self.PROJECT_NAME_2, + email=self.scapegoat["email"], + ) + assert len(project_users) == 1 + user = project_users[0] + assert isinstance(user.get("user_permissions"), list) + granted_names = {p.get("name") for p in user["user_permissions"]} + assert permission in granted_names + + def test_list_users_user_permissions_team_level_not_affected_by_project_grant(self): + permission = "Download" + sa.add_contributors_to_project( + self.PROJECT_NAME_2, [self.scapegoat["email"]], "ProjectAdmin" + ) + sa.grant_project_user_permissions( + project=self.PROJECT_NAME_2, + permissions=[permission], + user=self.scapegoat["email"], + ) + team_users = sa.list_users(email=self.scapegoat["email"]) + assert len(team_users) == 1 + user = team_users[0] + assert isinstance(user.get("user_permissions"), list) + team_perm_names = {p.get("name") for p in user["user_permissions"]} + assert permission not in team_perm_names diff --git a/tests/integration/work_management/test_project_user_permissions.py b/tests/integration/work_management/test_project_user_permissions.py new file mode 100644 index 000000000..ed0f044a3 --- /dev/null +++ b/tests/integration/work_management/test_project_user_permissions.py @@ -0,0 +1,232 @@ +from unittest import TestCase + +from lib.core.exceptions import AppException +from src.superannotate import SAClient + +sa = SAClient() + + +class TestProjectUserPermissions(TestCase): + PROJECT_NAME = "TestProjectUserPermissions" + PROJECT_TYPE = "Vector" + PROJECT_DESCRIPTION = "DESCRIPTION" + PERMISSION = "Download" + + @classmethod + def setUpClass(cls, *args, **kwargs) -> None: + cls.tearDownClass() + cls._project = sa.create_project( + cls.PROJECT_NAME, cls.PROJECT_DESCRIPTION, cls.PROJECT_TYPE + ) + 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"]], "ProjectAdmin" + ) + + @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): + try: + sa.revoke_project_user_permissions( + project=self.PROJECT_NAME, + permissions="*", + user=self.scapegoat["email"], + ) + except Exception: + pass + + def test_grant_permission_by_email(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + assert ( + f"INFO:sa:Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." == cm.output[0] + ) + + def test_grant_permission_by_project_id_and_user_id(self): + project = sa.get_project_metadata(self.PROJECT_NAME) + project_user_id = sa.list_users( + project=self.PROJECT_NAME, email=self.scapegoat["email"] + )[0]["id"] + + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=project["id"], + permissions=[self.PERMISSION], + user=project_user_id, + ) + assert ( + f"INFO:sa:Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." == cm.output[0] + ) + + def test_grant_all_permissions_wildcard(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions="*", + user=self.scapegoat["email"], + ) + assert cm.output[0].startswith("INFO:sa:Successfully granted [") + assert cm.output[0].endswith( + f"] permission(s) for user: {self.scapegoat['email']}." + ) + + def test_grant_already_granted_logs_failure(self): + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not grant [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert "Possible reasons:" in joined + assert ( + f"User already has [{self.PERMISSION}] permission(s) granted." in joined + ) + + def test_revoke_permission(self): + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + assert ( + f"INFO:sa:Successfully revoked [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." == cm.output[0] + ) + + def test_revoke_already_revoked_logs_failure(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.revoke_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not revoke [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert ( + f"[{self.PERMISSION}] permission(s) were already revoked for the user." + in joined + ) + + def test_grant_invalid_permission_logs_failure(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=["NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert "Provided permission(s) were invalid." in joined + + def test_grant_mixed_valid_and_invalid_logs_both(self): + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION, "NonExistentPermission"], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Successfully granted [{self.PERMISSION}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert ( + f"Could not grant [NonExistentPermission] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert "Provided permission(s) were invalid." in joined + + def test_grant_mixed_new_and_already_granted_logs_both(self): + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user=self.scapegoat["email"], + ) + permission_groups = ( + sa.controller.service_provider.work_management.list_permission_groups().data + or [] + ) + other = next( + (p.name for p in permission_groups if p.name and p.name != self.PERMISSION), + None, + ) + if not other: + self.skipTest("Need at least 2 permission groups for mixed scenario.") + with self.assertLogs("sa", level="INFO") as cm: + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION, other], + user=self.scapegoat["email"], + ) + joined = "\n".join(cm.output) + assert ( + f"Could not grant [{self.PERMISSION}, {other}] permission(s) " + f"for user: {self.scapegoat['email']}." in joined + ) + assert ( + f"User already has [{self.PERMISSION}, {other}] permission(s) granted." + in joined + ) + + def test_grant_empty_permissions_raises(self): + with self.assertRaisesRegex(AppException, r"Permission\(s\) cannot be empty\."): + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[], + user=self.scapegoat["email"], + ) + + def test_revoke_empty_permissions_raises(self): + with self.assertRaisesRegex(AppException, r"Permission\(s\) cannot be empty\."): + sa.revoke_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[], + user=self.scapegoat["email"], + ) + + def test_grant_unknown_user_raises(self): + with self.assertRaisesRegex(AppException, "User not found."): + sa.grant_project_user_permissions( + project=self.PROJECT_NAME, + permissions=[self.PERMISSION], + user="non_existent_user@superannotate.com", + )