From d28b85cf95e086796702afa61c3fd3dcba03ed8a Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 29 Apr 2026 10:51:21 +0400 Subject: [PATCH 01/27] Add ability to query.count --- src/superannotate/__init__.py | 2 +- .../lib/app/interface/responses.py | 78 +++++++++++++++++++ .../lib/app/interface/sdk_interface.py | 47 +++++++++-- tests/integration/items/test_saqul_query.py | 74 ++++++++++++++++++ 4 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 src/superannotate/lib/app/interface/responses.py diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index c10018bb..ebed1f89 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.4dev1" +__version__ = "4.5.5dev2" os.environ.update({"sa_version": __version__}) diff --git a/src/superannotate/lib/app/interface/responses.py b/src/superannotate/lib/app/interface/responses.py new file mode 100644 index 00000000..ad4e2b0f --- /dev/null +++ b/src/superannotate/lib/app/interface/responses.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Callable +from typing import Generic +from typing import Iterator +from typing import TypeVar +from typing import overload + +T = TypeVar("T") + + +class BaseResult(Generic[T]): + """A generic list-like wrapper for results with lazy loading support. + + This class wraps a list of results while maintaining full backward + compatibility with list-like operations (iteration, indexing, len()). + Data is fetched lazily on first access. + """ + + def __init__(self, data_fetcher: Callable[[], list[T]]) -> None: + self._data: list[T] | None = None + self._data_fetcher = data_fetcher + + def _ensure_data(self) -> list[T]: + """Lazily fetch data if not already loaded.""" + if self._data is None: + self._data = self._data_fetcher() + return self._data + + def __iter__(self) -> Iterator[T]: + return iter(self._ensure_data()) + + def __len__(self) -> int: + return len(self._ensure_data()) + + @overload + def __getitem__(self, index: int) -> T: ... + + @overload + def __getitem__(self, index: slice) -> list[T]: ... + + def __getitem__(self, index: int | slice) -> T | list[T]: + return self._ensure_data()[index] + + def __repr__(self) -> str: + return repr(self._ensure_data()) + + def __bool__(self) -> bool: + return bool(self._ensure_data()) + + def __contains__(self, item: T) -> bool: + return item in self._ensure_data() + + +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 dae44d8a..5b4eafb0 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 @@ -80,6 +81,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)] @@ -4267,10 +4270,14 @@ 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 object behaves like a list of dicts (supports iteration, + indexing, and ``len()``) and additionally exposes a ``.count()`` method + that returns the total number of matching items without fetching them. + :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]] @@ -4283,13 +4290,39 @@ def query( :return: queried items' metadata list :rtype: list of dicts + + Request Example: + :: + + client = SAClient() + + # Iterate over queried items (fetches data) + queried_items = client.query( + project="Image Project", + query="instance(error = true)" + ) + for item in queried_items: + print(item["name"]) + + # Get only the count without fetching all items + total = client.query( + project="Image Project", + query="instance(error = true)" + ).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) + project_entity, folder = self.controller.get_project_folder(project) + fetch_entities = partial( + self.controller.query_entities, project_entity, folder, query, subset + ) + return QueryResult( + data_fetcher=lambda: BaseSerializer.serialize_iterable( + fetch_entities(), exclude={"meta"} + ), + count_fetcher=partial( + self.controller.query_items_count, project_entity.name, query + ), + ) def get_item_metadata( self, diff --git a/tests/integration/items/test_saqul_query.py b/tests/integration/items/test_saqul_query.py index 1e964c3b..aa810691 100644 --- a/tests/integration/items/test_saqul_query.py +++ b/tests/integration/items/test_saqul_query.py @@ -66,6 +66,80 @@ def test_query_on_100(self): == 100 ) + def test_query_result_list_like_behavior(self): + """Test that QueryResult behaves like a list for backward compatibility.""" + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + + # Test len() + self.assertEqual(len(result), 100) + + # Test indexing + first_item = result[0] + self.assertIsInstance(first_item, dict) + self.assertIn("name", first_item) + + # Test negative indexing + last_item = result[-1] + self.assertIsInstance(last_item, dict) + + # Test slicing + sliced = result[0:5] + self.assertEqual(len(sliced), 5) + + # Test iteration + count = 0 + for item in result: + self.assertIsInstance(item, dict) + count += 1 + self.assertEqual(count, 100) + + # Test list conversion + as_list = list(result) + self.assertEqual(len(as_list), 100) + self.assertIsInstance(as_list, list) + + def test_query_result_count_method(self): + """Test that QueryResult.count() returns the count from server.""" + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + + # Test .count() method + count = result.count() + self.assertEqual(count, 100) + self.assertIsInstance(count, int) + + # Verify count matches len + self.assertEqual(count, len(result)) + + def test_query_result_lazy_loading(self): + """Test that QueryResult.count() does not trigger data fetching.""" + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + + # Data should not be loaded yet + self.assertIsNone(result._data) + + # Calling count() should not load data + count = result.count() + self.assertEqual(count, 100) + self.assertIsNone(result._data) + + # Accessing data should trigger loading + first_item = result[0] + self.assertIsNotNone(result._data) + self.assertIsInstance(first_item, dict) + + def test_query_result_repr(self): + """Test that QueryResult repr shows the underlying list.""" + sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) + result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") + + # Test __repr__ + repr_str = repr(result) + self.assertIsInstance(repr_str, str) + self.assertTrue(repr_str.startswith("[")) + def test_validate_saqul_query(self): try: self.assertRaises( From 2fb9efe70599e6039c9260fc59ceff2043f425f6 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 29 Apr 2026 10:57:03 +0400 Subject: [PATCH 02/27] feat(query): return lazy QueryResult with .count() from SAClient.query() - Add generic BaseResult[T] and QueryResult in app/interface/responses.py - QueryResult is list-like (iter, len, getitem) and lazy-loads data - .count() fetches count without triggering full data fetch - Update SAClient.query() docstring with usage example - Update tests to compare count() with paginated len() --- .pre-commit-config.yaml | 2 +- .../lib/app/interface/responses.py | 6 +- tests/integration/items/test_saqul_query.py | 76 +++---------------- 3 files changed, 15 insertions(+), 69 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f762fbee..a5a5da18 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: diff --git a/src/superannotate/lib/app/interface/responses.py b/src/superannotate/lib/app/interface/responses.py index ad4e2b0f..887dc08f 100644 --- a/src/superannotate/lib/app/interface/responses.py +++ b/src/superannotate/lib/app/interface/responses.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import Callable +from collections.abc import Callable +from collections.abc import Iterator from typing import Generic -from typing import Iterator -from typing import TypeVar from typing import overload +from typing import TypeVar T = TypeVar("T") diff --git a/tests/integration/items/test_saqul_query.py b/tests/integration/items/test_saqul_query.py index aa810691..5630dff0 100644 --- a/tests/integration/items/test_saqul_query.py +++ b/tests/integration/items/test_saqul_query.py @@ -59,86 +59,32 @@ 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): - """Test that QueryResult behaves like a list for backward compatibility.""" sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") - # Test len() 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) - # Test indexing - first_item = result[0] - self.assertIsInstance(first_item, dict) - self.assertIn("name", first_item) - - # Test negative indexing - last_item = result[-1] - self.assertIsInstance(last_item, dict) - - # Test slicing - sliced = result[0:5] - self.assertEqual(len(sliced), 5) - - # Test iteration - count = 0 - for item in result: - self.assertIsInstance(item, dict) - count += 1 - self.assertEqual(count, 100) - - # Test list conversion - as_list = list(result) - self.assertEqual(len(as_list), 100) - self.assertIsInstance(as_list, list) - - def test_query_result_count_method(self): - """Test that QueryResult.count() returns the count from server.""" - sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) - result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") - - # Test .count() method - count = result.count() - self.assertEqual(count, 100) - self.assertIsInstance(count, int) - - # Verify count matches len - self.assertEqual(count, len(result)) + items = [item for item in result] + self.assertEqual(len(items), 100) + self.assertIsInstance(list(result), list) - def test_query_result_lazy_loading(self): - """Test that QueryResult.count() does not trigger data fetching.""" + 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)") - # Data should not be loaded yet self.assertIsNone(result._data) - - # Calling count() should not load data - count = result.count() - self.assertEqual(count, 100) + self.assertEqual(result.count(), 100) self.assertIsNone(result._data) - # Accessing data should trigger loading - first_item = result[0] + _ = result[0] self.assertIsNotNone(result._data) - self.assertIsInstance(first_item, dict) - - def test_query_result_repr(self): - """Test that QueryResult repr shows the underlying list.""" - sa.attach_items(self.PROJECT_NAME, os.path.join(DATA_SET_PATH, "100_urls.csv")) - result = sa.query(self.PROJECT_NAME, "metadata(status = NotStarted)") - - # Test __repr__ - repr_str = repr(result) - self.assertIsInstance(repr_str, str) - self.assertTrue(repr_str.startswith("[")) def test_validate_saqul_query(self): try: From c191ee944fa70fe0d1c550ef8290c08584c3f2fd Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 29 Apr 2026 11:33:38 +0400 Subject: [PATCH 03/27] Update docsstring --- .../lib/app/interface/responses.py | 3 ++ .../lib/app/interface/sdk_interface.py | 36 ++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/superannotate/lib/app/interface/responses.py b/src/superannotate/lib/app/interface/responses.py index 887dc08f..01734191 100644 --- a/src/superannotate/lib/app/interface/responses.py +++ b/src/superannotate/lib/app/interface/responses.py @@ -27,6 +27,9 @@ def _ensure_data(self) -> list[T]: self._data = self._data_fetcher() return self._data + def data(self) -> list[T]: + return self._ensure_data() + def __iter__(self) -> Iterator[T]: return iter(self._ensure_data()) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 5b4eafb0..210e034a 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4274,9 +4274,9 @@ def query( """Return items that satisfy the given query. Query syntax should be in SuperAnnotate query language(https://doc.superannotate.com/docs/explore-overview). - The returned object behaves like a list of dicts (supports iteration, - indexing, and ``len()``) and additionally exposes a ``.count()`` method - that returns the total number of matching items without fetching them. + The returned :class:`QueryResult` behaves like a list of dicts (supports + iteration, indexing, and ``len()``) 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]] @@ -4288,15 +4288,14 @@ def query( To return all the items in the specified subset, set the value of query param to None. :type subset: str - :return: queried items' metadata list - :rtype: list of dicts + :return: queried items' metadata list with a ``.count()`` method + :rtype: QueryResult (list of dicts with .count() method) Request Example: :: client = SAClient() - # Iterate over queried items (fetches data) queried_items = client.query( project="Image Project", query="instance(error = true)" @@ -4304,12 +4303,25 @@ def query( for item in queried_items: print(item["name"]) - # Get only the count without fetching all items - total = client.query( - project="Image Project", - query="instance(error = true)" - ).count() - print(f"Total matching items: {total}") + .. py:method:: query.count() -> int + + Returns the total number of items matching the query without + fetching them. This is a lightweight call that does not trigger + pagination. + + :return: total number of matching items + :rtype: int + + Request Example: + :: + + client = SAClient() + + total = client.query( + project="Image Project", + query="instance(error = true)" + ).count() + print(f"Total matching items: {total}") """ project_entity, folder = self.controller.get_project_folder(project) fetch_entities = partial( From 39b5cb4e87470cfe0a2cd0d38e459b5ad5579b2f Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 30 Apr 2026 11:00:41 +0400 Subject: [PATCH 04/27] remove dead code --- .pre-commit-config.yaml | 4 +- docs/source/conf.py | 2 +- .../lib/app/analytics/aggregators.py | 8 - src/superannotate/lib/app/analytics/common.py | 48 ----- .../lib/app/input_converters/conversion.py | 10 - .../coco_converters/coco_to_sa_pixel.py | 19 -- .../coco_converters/sa_pixel_to_coco.py | 31 --- .../supervisely_strategies.py | 4 +- .../converters/voc_converters/voc_helper.py | 11 - .../lib/app/input_converters/sa_conversion.py | 8 - .../lib/app/interface/sdk_interface.py | 2 - src/superannotate/lib/core/__init__.py | 6 - src/superannotate/lib/core/entities/base.py | 5 - .../lib/core/entities/classes.py | 5 - .../lib/core/entities/multimodal_form.py | 7 - .../lib/core/entities/work_managament.py | 8 - src/superannotate/lib/core/service_types.py | 25 --- .../lib/core/usecases/annotations.py | 21 -- src/superannotate/lib/core/usecases/items.py | 192 ------------------ .../lib/infrastructure/controller.py | 25 --- .../lib/infrastructure/serviceprovider.py | 1 - .../lib/infrastructure/services/annotation.py | 3 +- .../infrastructure/services/item_service.py | 1 - .../lib/infrastructure/stream_data_handler.py | 7 - .../lib/infrastructure/validators.py | 20 -- 25 files changed, 7 insertions(+), 466 deletions(-) delete mode 100644 src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py delete mode 100644 src/superannotate/lib/app/input_converters/converters/coco_converters/sa_pixel_to_coco.py delete mode 100644 src/superannotate/lib/app/input_converters/sa_conversion.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5a5da18..0015d7c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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/docs/source/conf.py b/docs/source/conf.py index 852b95ab..09f0ad72 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/src/superannotate/lib/app/analytics/aggregators.py b/src/superannotate/lib/app/analytics/aggregators.py index b3ef289f..1bd84209 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 85222d98..dc944bc2 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 57f28855..dd486361 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 cd213c0b..00000000 --- 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 92af1638..00000000 --- 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 82288a59..428f09f5 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 7c8795ff..24d73b9e 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 de2e0244..00000000 --- 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/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 210e034a..de3523c5 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -104,8 +104,6 @@ ANNOTATION_TYPE = Literal["bbox", "polygon", "point", "tag"] -ANNOTATOR_ROLE = Literal["Admin", "Annotator", "QA"] - FOLDER_STATUS = Literal["NotStarted", "InProgress", "Completed", "OnHold"] diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 4bfb7278..c7d0964b 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." diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index a1beb230..008f6ea6 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 8075589c..ff9c440c 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 72d79732..0ae732fd 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 a66c1fe0..2e3c8853 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") diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index b5f95a93..78aaaf55 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -7,7 +7,6 @@ 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 +165,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 +185,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 +201,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 @@ -268,15 +255,3 @@ class WMScoreListResponse(ServiceResponse): 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, -} diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 4fea9b7e..85801320 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 5a1f2177..b9248c49 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -470,198 +470,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. diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index febb40bf..5974d0b9 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -943,26 +943,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 +1632,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 diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index ec2590aa..c39979b9 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" diff --git a/src/superannotate/lib/infrastructure/services/annotation.py b/src/superannotate/lib/infrastructure/services/annotation.py index cc7ed0eb..5fed8fb6 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/item_service.py b/src/superannotate/lib/infrastructure/services/item_service.py index e8d52936..0528bd2f 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/stream_data_handler.py b/src/superannotate/lib/infrastructure/stream_data_handler.py index 9dcd90d4..27a25c1a 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/validators.py b/src/superannotate/lib/infrastructure/validators.py index a96b66f1..04d53582 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) From 600c54cb75589afbffb222221b053599a01672dd Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 30 Apr 2026 18:09:05 +0400 Subject: [PATCH 05/27] Update query.count --- .../lib/app/interface/responses.py | 46 ++++++++++++------- .../lib/app/interface/sdk_interface.py | 22 ++++----- tests/integration/items/test_saqul_query.py | 6 +-- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/superannotate/lib/app/interface/responses.py b/src/superannotate/lib/app/interface/responses.py index 01734191..a4bc1501 100644 --- a/src/superannotate/lib/app/interface/responses.py +++ b/src/superannotate/lib/app/interface/responses.py @@ -9,32 +9,36 @@ T = TypeVar("T") -class BaseResult(Generic[T]): +class BaseResult(list, Generic[T]): """A generic list-like wrapper for results with lazy loading support. - This class wraps a list of results while maintaining full backward - compatibility with list-like operations (iteration, indexing, len()). + 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: - self._data: list[T] | None = None + super().__init__() self._data_fetcher = data_fetcher + self._loaded = False - def _ensure_data(self) -> list[T]: + def _ensure_data(self) -> None: """Lazily fetch data if not already loaded.""" - if self._data is None: - self._data = self._data_fetcher() - return self._data + if not self._loaded: + list.extend(self, self._data_fetcher()) + self._loaded = True def data(self) -> list[T]: - return self._ensure_data() + self._ensure_data() + return list(self) def __iter__(self) -> Iterator[T]: - return iter(self._ensure_data()) + self._ensure_data() + return list.__iter__(self) def __len__(self) -> int: - return len(self._ensure_data()) + self._ensure_data() + return list.__len__(self) @overload def __getitem__(self, index: int) -> T: ... @@ -43,16 +47,26 @@ def __getitem__(self, index: int) -> T: ... def __getitem__(self, index: slice) -> list[T]: ... def __getitem__(self, index: int | slice) -> T | list[T]: - return self._ensure_data()[index] + self._ensure_data() + return list.__getitem__(self, index) def __repr__(self) -> str: - return repr(self._ensure_data()) + self._ensure_data() + return list.__repr__(self) def __bool__(self) -> bool: - return bool(self._ensure_data()) + 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) - def __contains__(self, item: T) -> bool: - return item in self._ensure_data() + __hash__ = None # type: ignore[assignment] class QueryResult(BaseResult[dict]): diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 210e034a..5bedf62b 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4274,9 +4274,7 @@ def query( """Return items that satisfy the given query. Query syntax should be in SuperAnnotate query language(https://doc.superannotate.com/docs/explore-overview). - The returned :class:`QueryResult` behaves like a list of dicts (supports - iteration, indexing, and ``len()``) and additionally exposes a - ``.count()`` method. + 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]] @@ -4288,26 +4286,24 @@ def query( To return all the items in the specified subset, set the value of query param to None. :type subset: str - :return: queried items' metadata list with a ``.count()`` method + :return: queried items' metadata list :rtype: QueryResult (list of dicts with .count() method) Request Example: :: - client = SAClient() + sa_client = SAClient() - queried_items = client.query( + queried_items = sa_client.query( project="Image Project", - query="instance(error = true)" + 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 without - fetching them. This is a lightweight call that does not trigger - pagination. + Returns the total number of items matching the query. :return: total number of matching items :rtype: int @@ -4315,11 +4311,11 @@ def query( Request Example: :: - client = SAClient() + sa_client = SAClient() - total = client.query( + total = sa_client.query( project="Image Project", - query="instance(error = true)" + query="metadata(lastAction.email = test@superannotate.com)" ).count() print(f"Total matching items: {total}") """ diff --git a/tests/integration/items/test_saqul_query.py b/tests/integration/items/test_saqul_query.py index 5630dff0..9107ada2 100644 --- a/tests/integration/items/test_saqul_query.py +++ b/tests/integration/items/test_saqul_query.py @@ -79,12 +79,12 @@ 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.assertIsNone(result._data) + self.assertFalse(result._loaded) self.assertEqual(result.count(), 100) - self.assertIsNone(result._data) + self.assertFalse(result._loaded) _ = result[0] - self.assertIsNotNone(result._data) + self.assertTrue(result._loaded) def test_validate_saqul_query(self): try: From b472b6a248129ed24f040083c297cec3657ab268 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 5 May 2026 19:10:20 +0400 Subject: [PATCH 06/27] fix in ItemContext --- .../lib/app/interface/sdk_interface.py | 6 +- tests/integration/items/test_item_context.py | 64 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 5bedf62b..d366536f 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -155,6 +155,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( @@ -224,7 +225,9 @@ def save(self): self._set_large_annotation_adapter(self.annotation) else: self._set_small_annotation_adapter(self.annotation) - self._annotation_adapter.save() + if self._set_component_called: + self._annotation_adapter.save() + self._set_component_called = False def get_metadata(self): """ @@ -284,6 +287,7 @@ def set_component_value(self, component_id: str, value: Any): """ self.annotation_adapter.set_component_value(component_id, value) + self._set_component_called = True return self diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 454e4879..7bcd7ede 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() From 086ac5d6e28c56d3e6216b64852c9b69d2eaf7c4 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 6 May 2026 10:53:52 +0400 Subject: [PATCH 07/27] Update query.count --- .../lib/app/interface/sdk_interface.py | 10 +++-- .../lib/core/serviceproviders.py | 2 + src/superannotate/lib/core/usecases/items.py | 37 ++++++++++++++++++- .../lib/infrastructure/controller.py | 11 +++++- .../lib/infrastructure/services/explore.py | 6 +++ tests/integration/items/test_saqul_query.py | 18 +++++++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 5bedf62b..31cc1be2 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4319,16 +4319,20 @@ def query( ).count() print(f"Total matching items: {total}") """ - project_entity, folder = self.controller.get_project_folder(project) + project, folder = self.controller.get_project_folder(project) fetch_entities = partial( - self.controller.query_entities, project_entity, folder, query, subset + 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_entity.name, query + self.controller.query_items_count, + project=project, + folder=folder, + query=query, + subset=subset, ), ) diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index 6a28e04e..d92de4b9 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -750,7 +750,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/items.py b/src/superannotate/lib/core/usecases/items.py index 5a1f2177..41bd8c80 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, + subset: str = 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, diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index febb40bf..985444b5 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -2039,13 +2039,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, + subset: str = 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/services/explore.py b/src/superannotate/lib/infrastructure/services/explore.py index 4afbd544..341fae49 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/tests/integration/items/test_saqul_query.py b/tests/integration/items/test_saqul_query.py index 9107ada2..4a2a2412 100644 --- a/tests/integration/items/test_saqul_query.py +++ b/tests/integration/items/test_saqul_query.py @@ -86,6 +86,24 @@ def test_query_result_lazy_count(self): _ = 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( From eaa59a52f7d349fca36ed36c489d9b2c8e97e480 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 6 May 2026 16:34:34 +0400 Subject: [PATCH 08/27] fix in set_annoation_status --- src/superannotate/lib/core/usecases/items.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 41bd8c80..fbed9f53 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -897,7 +897,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 From 617d4320a5a02844a071d28021ca732886d614de Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 14 May 2026 17:00:20 +0400 Subject: [PATCH 09/27] Update function arguments to support project ID --- pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 306 +++++++++--------- .../lib/infrastructure/controller.py | 42 ++- ...te_annotation_classes_from_classes_json.py | 25 ++ .../custom_fields/test_custom_schema.py | 11 + tests/integration/export/test_export.py | 17 + .../integration/folders/test_create_folder.py | 6 + .../folders/test_delete_folders.py | 9 + .../folders/test_get_folder_metadata.py | 7 + .../folders/test_search_folders.py | 10 + .../folders/test_set_folder_status.py | 11 + .../projects/test_clone_project.py | 15 + .../projects/test_get_project_metadata.py | 7 + .../projects/test_project_rename.py | 7 + .../projects/test_set_project_status.py | 6 + tests/integration/subsets/test_subsets.py | 21 ++ 16 files changed, 330 insertions(+), 172 deletions(-) diff --git a/pytest.ini b/pytest.ini index d9f7f6cc..c0f66b58 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/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 3e1fe8bb..f25931c1 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -51,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 @@ -63,7 +68,6 @@ 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 @@ -141,7 +145,7 @@ class ItemContext: def __init__( self, controller: Controller, - project: Project, + project: ProjectEntity, folder: FolderEntity, item: BaseItemEntity, overwrite: bool = True, @@ -175,7 +179,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] @@ -325,10 +329,10 @@ def get_project_by_id(self, project_id: int): 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 @@ -643,7 +647,7 @@ def list_users( project="my_multimodal", email__contains="@superannotate.com", custom_field__speed__gte=90, - custom_field__weight__lte=1, + custom_field__weight__lte=1 ) Response Example: @@ -1290,8 +1294,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, @@ -1305,7 +1309,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 @@ -1559,11 +1563,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 @@ -1596,17 +1600,18 @@ def delete_project(self, project: NotEmptyStr | dict): name = project["name"] self.controller.projects.delete(name=name) - def rename_project(self, project: NotEmptyStr, new_name: NotEmptyStr): + 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: @@ -1618,7 +1623,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, ): @@ -1627,7 +1632,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 @@ -1702,10 +1707,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 @@ -1754,7 +1761,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: @@ -1882,7 +1894,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, @@ -1892,7 +1904,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 @@ -1973,8 +1985,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, @@ -1993,19 +2004,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 + :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() @@ -2013,13 +2023,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 + :type project: str or ID :return: A list of step dictionaries, or a dictionary containing both steps and their connections (for Keypoint workflows). @@ -2073,19 +2083,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, @@ -2095,8 +2104,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( @@ -2350,10 +2358,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. @@ -2416,14 +2424,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 @@ -2435,7 +2446,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: @@ -2446,19 +2458,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 + :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}], @@ -2628,7 +2639,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] @@ -2649,7 +2660,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) @@ -2680,7 +2691,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( @@ -2707,7 +2718,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.", @@ -2716,10 +2729,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, @@ -2783,10 +2796,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 @@ -2795,14 +2810,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, @@ -2812,7 +2828,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 @@ -2852,9 +2868,9 @@ def prepare_export( client.download_export("Project Name", export, "path_to_download") """ - project_name, folder_name = extract_project_folder(project) + project, folder = self.controller.get_project_folder(project) if folder_names is None: - folders = [folder_name] if folder_name else [] + folders = [folder.name] if folder else [] else: folders = folder_names integration_name = kwargs.get("integration_name") @@ -2875,7 +2891,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, @@ -2931,12 +2947,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, @@ -2951,7 +2967,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) @@ -2988,7 +3004,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( @@ -3017,8 +3033,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, @@ -3033,7 +3049,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, @@ -3048,7 +3064,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) @@ -3076,7 +3092,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( @@ -3085,8 +3101,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, @@ -3231,11 +3247,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 @@ -3259,11 +3275,11 @@ def delete_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 @@ -3273,7 +3289,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)}." ) @@ -3290,14 +3306,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 @@ -3327,7 +3343,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, @@ -3338,7 +3354,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, @@ -3346,7 +3362,7 @@ def download_export( ): """Download prepared export. - :param project: project name + :param project: project name or ID :type project: str :param export: export name @@ -3362,11 +3378,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, @@ -3377,14 +3393,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 + :type project: str or int :param steps: new workflow list of dicts :type steps: list of dicts @@ -3443,8 +3459,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 ) @@ -3779,7 +3794,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, @@ -3790,7 +3805,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) @@ -3811,7 +3826,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( @@ -3820,8 +3835,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, @@ -3833,7 +3848,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, @@ -3847,7 +3862,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 @@ -3874,11 +3889,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, @@ -3972,6 +3987,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) @@ -3985,13 +4001,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 @@ -4003,7 +4019,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, @@ -4186,7 +4202,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, *, @@ -4198,7 +4214,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”. @@ -4243,7 +4259,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) @@ -5002,8 +5018,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[ @@ -5013,10 +5029,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 @@ -5045,17 +5061,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, @@ -5067,8 +5082,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" @@ -5077,10 +5092,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 @@ -5099,17 +5114,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, @@ -5295,28 +5309,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. @@ -5384,8 +5397,7 @@ def create_custom_fields(self, project: NotEmptyStr, fields: dict): ) """ - 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 ) @@ -5393,10 +5405,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 @@ -5432,19 +5444,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 @@ -5486,8 +5499,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 ) @@ -5609,13 +5621,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. @@ -5683,8 +5694,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) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index f88edd7b..ba6fbad6 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -1658,8 +1658,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 @@ -1672,16 +1679,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)): @@ -1710,15 +1715,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, @@ -1736,7 +1739,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, @@ -1745,7 +1748,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 @@ -1769,7 +1771,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, @@ -1777,7 +1779,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, @@ -1841,9 +1842,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, @@ -1880,13 +1879,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, @@ -1939,8 +1937,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, @@ -1950,8 +1948,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 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 e918bdd7..cbb2f27a 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 539b0b20..fb06765d 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 5b0d8cd4..b97c844e 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 68952c79..5ee8f270 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 edd85d13..ab605b9c 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 6d7af552..376de7e3 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 c368d877..9e132516 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 5c1b78fc..3c81d098 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/projects/test_clone_project.py b/tests/integration/projects/test_clone_project.py index e186ce2f..035c6b40 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 ae22cc95..43f7b80f 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 c18ec64e..d63eebed 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 77b1260e..042eefc9 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/subsets/test_subsets.py b/tests/integration/subsets/test_subsets.py index 088f3617..b92d7f70 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] From f29798eb83b227fcdadb91e488e233ddbbb96310 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 14 May 2026 17:03:23 +0400 Subject: [PATCH 10/27] fix in ItemContext --- src/superannotate/lib/app/interface/sdk_interface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 3e1fe8bb..bb2ca159 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -215,7 +215,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): @@ -223,8 +224,7 @@ def save(self): self._set_large_annotation_adapter(self.annotation) else: self._set_small_annotation_adapter(self.annotation) - if self._set_component_called: - self._annotation_adapter.save() + self._annotation_adapter.save() self._set_component_called = False def get_metadata(self): From 1ed44353bc9c787d73df988845bbd3b87d71a93d Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 14 May 2026 17:27:11 +0400 Subject: [PATCH 11/27] Fix upload_images_to_project log msg --- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index f25931c1..dadd7d0f 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3901,7 +3901,7 @@ 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}.") + logger.info(f"Uploading {len(images_to_upload)} images to project {project.name}.") uploaded, failed_images = [], [] if not images_to_upload: return uploaded, failed_images, existing_items From 1adb70cad20627e9116190a125b569f7892c0d78 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 18 May 2026 14:42:17 +0400 Subject: [PATCH 12/27] Add setting MaxIdleDuration --- .../lib/app/interface/sdk_interface.py | 4 +- src/superannotate/lib/core/__init__.py | 1 + tests/integration/settings/test_settings.py | 38 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index dadd7d0f..08f7802b 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3901,7 +3901,9 @@ 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.name}.") + logger.info( + f"Uploading {len(images_to_upload)} images to project {project.name}." + ) uploaded, failed_images = [], [] if not images_to_upload: return uploaded, failed_images, existing_items diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index c7d0964b..77f51b04 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -183,6 +183,7 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): "ImageAutoAssignEnable", "TemplateState", "CategorizeItems", + "MaxIdleDuration", ] __alL__ = ( diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index 7aaa1d4f..3ce9feb0 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 From aab5e93d99d7b37a3b1a3975e41efb9fc2a3a1ad Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 19 May 2026 14:48:06 +0400 Subject: [PATCH 13/27] add project_user permissions grant revoke --- docs/source/api_reference/api_project.rst | 2 + pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 166 ++++++++++++- .../lib/core/entities/work_managament.py | 19 ++ src/superannotate/lib/core/service_types.py | 5 + .../lib/core/serviceproviders.py | 16 ++ .../lib/infrastructure/controller.py | 120 ++++++++- .../services/work_management.py | 73 ++++++ .../work_management/test_list_users.py | 41 ++++ .../test_project_user_permissions.py | 232 ++++++++++++++++++ 10 files changed, 670 insertions(+), 6 deletions(-) create mode 100644 tests/integration/work_management/test_project_user_permissions.py diff --git a/docs/source/api_reference/api_project.rst b/docs/source/api_reference/api_project.rst index e9384102..4463a43d 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/pytest.ini b/pytest.ini index c0f66b58..d9f7f6cc 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/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index eb4efd4b..cc170afe 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -525,7 +525,9 @@ def list_users( self, *, project: NotEmptyStr | int | None = None, - include: list[Literal["custom_fields", "categories"]] | None = None, + include: ( + list[Literal["custom_fields", "categories", "project_permissions"]] | None + ) = None, **filters, ): """ @@ -542,6 +544,7 @@ def list_users( - "custom_fields": Includes custom fields and scores assigned to each user. - "categories": Includes a list of categories assigned to each project contributor. Note: 'project' parameter must be specified when including 'categories'. + - "project_permissions": Includes a list of project permissions granted to the user. :type include: list of str, optional :param filters: Specifies filtering criteria, with all conditions combined using logical AND. @@ -712,6 +715,47 @@ def list_users( } ] + Request Example: + :: + + # include project_permissions + + project_user = client.list_users( + project="my_multimodal", + email="test@superannotate.com", + include=["project_permissions"] + ) + + Response Example: + :: + + [ + { + 'categories': None, + '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 + }, + 'project_permissions': [ + { + 'id': 28, + 'name': 'Download', + 'createdAt': '2026-05-12T12:47:41.000Z', + 'updatedAt': '2026-05-12T12:47:41.000Z' + } + ], + 'role': 'ProjectAdmin', + 'state': 'Confirmed', + 'team_id': 32022, + 'updatedAt': '2026-05-19T08:28:55.000Z' + } + ] """ if project is not None: if isinstance(project, int): @@ -1022,6 +1066,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. @@ -3901,7 +4061,9 @@ 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.name}.") + logger.info( + f"Uploading {len(images_to_upload)} images to project {project.name}." + ) uploaded, failed_images = [], [] if not images_to_upload: return uploaded, failed_images, existing_items diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 2e3c8853..94e17c4c 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -115,6 +115,9 @@ class WMProjectUserEntity(TimedBaseModel): custom_fields: dict | None = Field(default_factory=dict, alias="customField") permissions: dict | None = None categories: list[dict] | None = None + project_permissions: list[PermissionEntity] | None = Field( + default=None, alias="userPermissions" + ) @field_validator("custom_fields", mode="before") @classmethod @@ -129,6 +132,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 78aaaf55..d625411a 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -3,6 +3,7 @@ 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 @@ -255,3 +256,7 @@ class WMScoreListResponse(ServiceResponse): class TelemetryScoreListResponse(ServiceResponse): res_data: list[TelemetryScoreEntity] = None + + +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 d92de4b9..54960f5a 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, diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index ba6fbad6..53e65119 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -196,7 +196,9 @@ def remove_users_from_project(self, project: ProjectEntity, user_emails: list[st def list_users( self, - include: list[Literal["custom_fields", "categories"]] = None, + include: list[ + Literal["custom_fields", "categories", "project_permissions"] + ] = None, project=None, **filters, ): @@ -244,8 +246,11 @@ def list_users( ) query = chain.handle(filters, EmptyQuery()) - if project and include and "categories" in include: - query &= Join("categories") + if project and include: + if "categories" in include: + query &= Join("categories") + if "project_permissions" in include: + query &= Join("userPermissions") if include and "custom_fields" in include: response = self.service_provider.work_management.list_users( @@ -513,6 +518,115 @@ 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.") + + all_permission_groups = ( + self.service_provider.work_management.list_permission_groups().data or [] + ) + project_user_permissions = [ + perm + for group in all_permission_groups + if group.label == "projectUser" + for perm in (group.permissions or []) + ] + name_by_id = { + p.id: p.name for p in project_user_permissions if p.id is not None + } + id_by_name_lower = { + p.name.lower(): p.id for p in project_user_permissions if p.name + } + + 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 = id_by_name_lower.get(name.lower()) + 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): diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index e5b32a41..bcdc931b 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -2,12 +2,15 @@ import base64 import json +import time +from functools import lru_cache from typing import Literal from lib.core.entities import CategoryEntity 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 +27,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 +80,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 +545,73 @@ def set_remove_contributor_categories( return success_contributors + @lru_cache(maxsize=1) + def _list_permission_groups_cached( + self, ttl: int # noqa: ARG002 - bucket key to invalidate cache + ) -> WMPermissionGroupListResponse: + del ttl + 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 list_permission_groups(self) -> WMPermissionGroupListResponse: + ttl_bucket = int(time.time() // 600) # 10min cache + response = self._list_permission_groups_cached(ttl=ttl_bucket) + if not response.ok: + self._list_permission_groups_cached.cache_clear() + return response + + 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/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py index ff013d48..c1f6a111 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,35 @@ 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_include_project_permissions(self): + project_users = sa.list_users( + project=self.PROJECT_NAME, + email=self.scapegoat["email"], + include=["project_permissions"], + ) + assert len(project_users) == 1 + user = project_users[0] + assert "project_permissions" in user + assert isinstance(user["project_permissions"], list) + + def test_list_users_include_project_permissions_after_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"], + ) + project_users = sa.list_users( + project=self.PROJECT_NAME_2, + email=self.scapegoat["email"], + include=["project_permissions"], + ) + assert len(project_users) == 1 + user = project_users[0] + assert isinstance(user.get("project_permissions"), list) + granted_names = {p.get("name") for p in user["project_permissions"]} + assert permission in granted_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 00000000..ed0f044a --- /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", + ) From c0ceefaadf88e18da160bbbbaee8a72fc0ec49d4 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 19 May 2026 16:36:58 +0400 Subject: [PATCH 14/27] feat(sdk): accept project/folder IDs across SDK methods - Widen signatures to NotEmptyStr | int (and tuple variants) for project/folder params - Centralize entity resolution via Controller.get_project / get_project_folder - Restore root-folder lookup in Controller.get_folder when name is None - Deprecate search_folders in favor of list_folders --- .../lib/app/interface/sdk_interface.py | 108 +++++------------- 1 file changed, 29 insertions(+), 79 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 08f7802b..1b7f4454 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -71,7 +71,6 @@ 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 @@ -322,7 +321,7 @@ 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) + response = self.controller.get_project(project_id=project_id) return ProjectSerializer(response.data).serialize() @@ -372,7 +371,7 @@ def get_item_by_id( :return: item metadata :rtype: dict """ - project_response = self.controller.get_project_by_id(project_id=project_id) + project_response = self.controller.get_project(project_id=project_id) project_response.raise_for_status() if ( @@ -714,10 +713,7 @@ def list_users( """ 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 @@ -958,11 +954,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( @@ -1008,11 +1000,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( @@ -1064,11 +1052,7 @@ def retrieve_context( 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." @@ -1429,11 +1413,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 = ( @@ -1483,11 +1463,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 = ( @@ -1529,11 +1505,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() @@ -1589,16 +1561,14 @@ def create_folder(self, project: NotEmptyStr | int, 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) + project = self.controller.get_project(project) + self.controller.projects.delete(name=project.name) def rename_project(self, project: NotEmptyStr | int, new_name: NotEmptyStr): """Renames the project @@ -1868,11 +1838,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( @@ -2410,11 +2376,7 @@ def set_project_custom_field( 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, @@ -2868,9 +2830,9 @@ def prepare_export( client.download_export("Project Name", export, "path_to_download") """ - project, folder = self.controller.get_project_folder(project) + project = self.controller.get_project(project) if folder_names is None: - folders = [folder.name] if folder else [] + folders = [] else: folders = folder_names integration_name = kwargs.get("integration_name") @@ -2933,11 +2895,7 @@ def delete_exports( 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 ) @@ -3268,7 +3226,7 @@ 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 @@ -3641,7 +3599,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( @@ -3649,7 +3607,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( @@ -3668,7 +3628,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, @@ -3715,7 +3674,6 @@ def upload_image_annotations( """ - _, folder_name = extract_project_folder(project) if keep_status is not None: warnings.warn( DeprecationWarning( @@ -3732,9 +3690,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) @@ -4161,7 +4116,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 ) @@ -4693,11 +4650,7 @@ def list_items( 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 @@ -5294,10 +5247,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, @@ -5823,7 +5773,7 @@ def item_context( 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 + project = self.controller.get_project(path[0]).data folder = self.controller.get_folder_by_id(path[1], project.id).data else: raise AppException("Invalid path provided.") From 79439cb9b07713f81e9313e6613fad4ecf905a49 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Tue, 19 May 2026 18:29:44 +0400 Subject: [PATCH 15/27] add project_user_permission in CachedWorkManagementRepository --- .../lib/infrastructure/controller.py | 18 +------ .../lib/infrastructure/serviceprovider.py | 10 ++++ .../services/work_management.py | 15 +----- src/superannotate/lib/infrastructure/utils.py | 49 +++++++++++++++++++ 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 53e65119..106ee9ed 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -536,21 +536,7 @@ def edit_project_user_permissions( if not project_users: raise AppException("User not found.") - all_permission_groups = ( - self.service_provider.work_management.list_permission_groups().data or [] - ) - project_user_permissions = [ - perm - for group in all_permission_groups - if group.label == "projectUser" - for perm in (group.permissions or []) - ] - name_by_id = { - p.id: p.name for p in project_user_permissions if p.id is not None - } - id_by_name_lower = { - p.name.lower(): p.id for p in project_user_permissions if p.name - } + name_by_id = self.service_provider.get_project_user_permission_id_name_map() if permissions == "*": resolved_ids = list(name_by_id.keys()) @@ -560,7 +546,7 @@ def edit_project_user_permissions( seen_ids: set = set() unresolved_names = [] for name in permissions: - pid = id_by_name_lower.get(name.lower()) + pid = self.service_provider.get_project_user_permission_id(name) if pid is None: unresolved_names.append(name) elif pid not in seen_ids: diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index c39979b9..3b1d7a38 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -156,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/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index bcdc931b..0a4d2b7d 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -2,8 +2,6 @@ import base64 import json -import time -from functools import lru_cache from typing import Literal from lib.core.entities import CategoryEntity @@ -545,11 +543,7 @@ def set_remove_contributor_categories( return success_contributors - @lru_cache(maxsize=1) - def _list_permission_groups_cached( - self, ttl: int # noqa: ARG002 - bucket key to invalidate cache - ) -> WMPermissionGroupListResponse: - del ttl + def list_permission_groups(self) -> WMPermissionGroupListResponse: return self.client.paginate( url=self.URL_PERMISSION_GROUPS, headers={ @@ -560,13 +554,6 @@ def _list_permission_groups_cached( item_type=PermissionGroupEntity, ) - def list_permission_groups(self) -> WMPermissionGroupListResponse: - ttl_bucket = int(time.time() // 600) # 10min cache - response = self._list_permission_groups_cached(ttl=ttl_bucket) - if not response.ok: - self._list_permission_groups_cached.cache_clear() - return response - def edit_project_user_permissions( self, project_id: int, diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index f682662d..490cb767 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) From eaaee4688c666c94013ba5122ed583baf6397eeb Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 20 May 2026 10:54:17 +0400 Subject: [PATCH 16/27] Update delete project --- src/superannotate/lib/app/interface/sdk_interface.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 674268d0..ec0a6f05 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -16,6 +16,7 @@ from typing import Annotated from typing import Any from typing import Literal +from xml.sax import SAXException from pydantic import Field from pydantic import StringConstraints @@ -1567,7 +1568,12 @@ def delete_project(self, project: NotEmptyStr | int): :param project: project name or ID :type project: str """ - project = self.controller.get_project(project) + 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): From 1b4eda13d94fdcc9d71df211ab4f081298a1b4a5 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 20 May 2026 11:10:35 +0400 Subject: [PATCH 17/27] Update delete project --- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index ec0a6f05..74ce1d19 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1571,7 +1571,7 @@ def delete_project(self, project: NotEmptyStr | int): try: project = self.controller.get_project(project) except AppException as e: - if str(e) == "Project not found": + if str(e) == "Project not found.": return raise self.controller.projects.delete(name=project.name) From cbd1db918d1e1268f931802a08c3aca86b35a381 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 20 May 2026 11:24:07 +0400 Subject: [PATCH 18/27] Update delete project --- pytest.ini | 2 +- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- tests/integration/settings/test_settings.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pytest.ini b/pytest.ini index c0f66b58..d9f7f6cc 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/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 74ce1d19..ec0a6f05 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1571,7 +1571,7 @@ def delete_project(self, project: NotEmptyStr | int): try: project = self.controller.get_project(project) except AppException as e: - if str(e) == "Project not found.": + if str(e) == "Project not found": return raise self.controller.projects.delete(name=project.name) diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index 3ce9feb0..25bc49bc 100644 --- a/tests/integration/settings/test_settings.py +++ b/tests/integration/settings/test_settings.py @@ -193,3 +193,7 @@ def test_frame_rate(self): if setting["attribute"] == "MaxIdleDuration": assert setting["value"] == 612 break + + +def test(): + sa.delete_project("1111") \ No newline at end of file From 55578f0d311bfa0a223a45ce217e96d8340928fe Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 20 May 2026 11:30:22 +0400 Subject: [PATCH 19/27] Update delete project --- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- src/superannotate/lib/core/usecases/projects.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index ec0a6f05..74ce1d19 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1571,7 +1571,7 @@ def delete_project(self, project: NotEmptyStr | int): try: project = self.controller.get_project(project) except AppException as e: - if str(e) == "Project not found": + if str(e) == "Project not found.": return raise self.controller.projects.delete(name=project.name) diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 653357e6..0a165d87 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 From 1505cd127c2128a9d0419836af9bddd70beb5a88 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 20 May 2026 15:22:18 +0400 Subject: [PATCH 20/27] Fix tests base class --- tests/integration/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/base.py b/tests/integration/base.py index 2b7a8344..dde39b6f 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: From 5682ecfa3d1c7c4278e12a644e047b8d6349641f Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 21 May 2026 13:57:32 +0400 Subject: [PATCH 21/27] Update docstrings --- src/superannotate/lib/app/interface/sdk_interface.py | 11 +++++------ tests/integration/base.py | 2 +- tests/integration/settings/test_settings.py | 4 ---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index bc984f68..ed939314 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -16,7 +16,6 @@ from typing import Annotated from typing import Any from typing import Literal -from xml.sax import SAXException from pydantic import Field from pydantic import StringConstraints @@ -2141,7 +2140,7 @@ def get_project_settings(self, project: NotEmptyStr | int): Return value example: [{ "attribute" : "Brightness", "value" : 10, ...},...] - :param project: project name or metadata + :param project: project name or ID :type project: str or ID :return: project settings @@ -2160,7 +2159,7 @@ def get_project_steps(self, project: NotEmptyStr | int): Return value example: [{ "step" : , "className" : , "tool" : , ...},...] - :param project: project name or metadata + :param project: project name or ID :type project: str or ID :return: A list of step dictionaries, @@ -2591,7 +2590,7 @@ def set_project_default_image_quality_in_editor( ): """Sets project's default image quality in editor setting. - :param project: project name or metadata + :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 @@ -2727,7 +2726,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 @@ -3523,7 +3522,7 @@ def set_project_steps( ): """Sets project's steps. - :param project: project name or metadata + :param project: project name or ID :type project: str or int :param steps: new workflow list of dicts diff --git a/tests/integration/base.py b/tests/integration/base.py index dde39b6f..f66cfa8e 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['id']) + sa.delete_project(project["id"]) except Exception as e: print(str(e)) except Exception as e: diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index 25bc49bc..3ce9feb0 100644 --- a/tests/integration/settings/test_settings.py +++ b/tests/integration/settings/test_settings.py @@ -193,7 +193,3 @@ def test_frame_rate(self): if setting["attribute"] == "MaxIdleDuration": assert setting["value"] == 612 break - - -def test(): - sa.delete_project("1111") \ No newline at end of file From 0415d1e85a8495f9ec70b66903f23c5b9d9b5203 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 21 May 2026 15:16:16 +0400 Subject: [PATCH 22/27] change in list_user pemissions --- .../lib/app/interface/sdk_interface.py | 204 +++++++++++------- .../lib/core/entities/work_managament.py | 5 +- .../lib/infrastructure/controller.py | 11 +- .../work_management/test_list_users.py | 45 +++- 4 files changed, 174 insertions(+), 91 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index cc170afe..3aed4ed4 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -241,7 +241,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) """ @@ -260,7 +260,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) """ @@ -282,7 +282,7 @@ 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) @@ -455,7 +455,7 @@ def get_user_metadata( Request Example: :: - client.get_user_metadata( + sa_client.get_user_metadata( "example@email.com", include=["custom_fields"] ) @@ -502,7 +502,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 @@ -525,9 +525,7 @@ def list_users( self, *, project: NotEmptyStr | int | None = None, - include: ( - list[Literal["custom_fields", "categories", "project_permissions"]] | None - ) = None, + include: list[Literal["custom_fields", "categories"]] | None = None, **filters, ): """ @@ -544,7 +542,6 @@ def list_users( - "custom_fields": Includes custom fields and scores assigned to each user. - "categories": Includes a list of categories assigned to each project contributor. Note: 'project' parameter must be specified when including 'categories'. - - "project_permissions": Includes a list of project permissions granted to the user. :type include: list of str, optional :param filters: Specifies filtering criteria, with all conditions combined using logical AND. @@ -603,7 +600,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 @@ -614,7 +611,7 @@ def list_users( Request Example: :: - client.list_users( + sa_client.list_users( email__contains="@superannotate.com", include=["custom_fields"], state__in=["Confirmed"] @@ -637,6 +634,7 @@ def list_users( "role": "TeamOwner", "state": "Confirmed", "team_id": 44311, + "user_permissions": [], } ] @@ -645,7 +643,7 @@ 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", @@ -665,9 +663,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": [], } ] @@ -681,7 +687,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", @@ -712,18 +718,30 @@ 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: :: - # include project_permissions - - project_user = client.list_users( + project_user = sa_client.list_users( project="my_multimodal", email="test@superannotate.com", - include=["project_permissions"] ) Response Example: @@ -731,7 +749,6 @@ def list_users( [ { - 'categories': None, 'createdAt': '2026-05-19T08:28:55.000Z', 'custom_fields': {}, 'email': 'test@superannotate.com', @@ -742,7 +759,12 @@ def list_users( 'allow_view_sdk_token': 0, 'paused': 0 }, - 'project_permissions': [ + 'role': 'ProjectAdmin', + 'state': 'Confirmed', + 'team_id': 32022, + 'updatedAt': '2026-05-19T08:28:55.000Z', + 'categories': None, + 'user_permissions': [ { 'id': 28, 'name': 'Download', @@ -750,10 +772,44 @@ def list_users( 'updatedAt': '2026-05-12T12:47:41.000Z' } ], - 'role': 'ProjectAdmin', + } + ] + + + 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' + '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' + }, + ], } ] """ @@ -849,7 +905,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", @@ -934,7 +990,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", @@ -987,13 +1043,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="*" @@ -1037,13 +1093,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="*" @@ -1581,7 +1637,7 @@ def create_categories( Request Example: :: - client.create_categories( + sa_client.create_categories( project="product-review-mm", categories=["Shoes", "T-Shirt"] ) @@ -1618,7 +1674,7 @@ def list_categories(self, project: NotEmptyStr | int): Request Example: :: - client.list_categories( + sa_client.list_categories( project="product-review-mm" ) @@ -1675,13 +1731,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="*" ) @@ -1997,7 +2053,7 @@ def list_folders( Request Example: :: - client.list_folders( + sa_client.list_folders( project="test_project", status="NotStarted" ) @@ -2096,7 +2152,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 @@ -2294,7 +2350,7 @@ def get_annotation_class( Request Example: :: - classes = client.get_annotation_class( + classes = sa_client.get_annotation_class( project="classes", annotation_class="Example_class" ) @@ -2564,7 +2620,7 @@ 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, @@ -3017,16 +3073,16 @@ 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, folder = self.controller.get_project_folder(project) if folder_names is None: @@ -3082,13 +3138,13 @@ 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="*" ) @@ -3362,7 +3418,7 @@ def create_annotation_class( "name": "Description" } ] - client.create_annotation_class( + sa_client.create_annotation_class( project="Image Project", name="Example Class", color="#F9E0FA", @@ -4338,7 +4394,7 @@ def get_integrations(self): Request Example: :: - client.get_integrations() + sa_client.get_integrations() Response Example: @@ -4408,7 +4464,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", @@ -4539,7 +4595,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 @@ -4629,7 +4685,7 @@ def search_items( Request Example: :: - client.search_items( + sa_client.search_items( project="Medical Annotations", name_contains="image_1", include_custom_metadata=True @@ -4776,7 +4832,7 @@ def list_items( Request Example: :: - client.list_items( + sa_client.list_items( project="Medical Annotations", folder="folder1", include=["custom_metadata"], @@ -4806,7 +4862,7 @@ def list_items( Request Example with include categories: :: - client.list_items( + sa_client.list_items( project="My Multimodal", folder="folder1", include=["categories"] @@ -4840,7 +4896,7 @@ def list_items( Additional Filter Examples: :: - client.list_items( + sa_client.list_items( project="Medical Annotations", folder="folder2", annotation_status="Completed", @@ -4848,7 +4904,7 @@ 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" ) @@ -4963,7 +5019,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 @@ -4973,7 +5029,7 @@ def list_projects( Request Example: :: - client.list_projects( + sa_client.list_projects( include=["custom_fields"], status__in=["InProgress", "Completed"], name__contains="Medical", @@ -5049,8 +5105,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://..."}] ) @@ -5058,8 +5114,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=[ { @@ -5315,7 +5371,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" @@ -5349,7 +5405,7 @@ def remove_items_category( Request Example: :: - client.remove_items_category( + sa_client.remove_items_category( project=("product-review-mm", "folder1"), items=[112233, 112344] ) @@ -5552,8 +5608,8 @@ def create_custom_fields(self, project: NotEmptyStr | int, fields: dict): } } - client = SAClient() - client.create_custom_fields( + sa_client = SAClient() + sa_client.create_custom_fields( project="Medical Annotations", fields=custom_fields ) @@ -5631,8 +5687,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] ) @@ -5693,7 +5749,7 @@ def upload_custom_values( Request Example: :: - client = SAClient() + sa_client = SAClient() items_values = [ { @@ -5722,7 +5778,7 @@ def upload_custom_values( } ] - client.upload_custom_values( + sa_client.upload_custom_values( project = "Medical Annotations", items = items_values ) @@ -5767,7 +5823,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"]}, @@ -5805,15 +5861,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 @@ -5830,7 +5886,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 @@ -5936,7 +5992,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) @@ -5945,7 +6001,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) @@ -5953,7 +6009,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) @@ -5961,7 +6017,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. @@ -5972,7 +6028,7 @@ 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}") @@ -6021,7 +6077,7 @@ def list_workflows(self): Request Example: :: - client.list_workflows() + sa_client.list_workflows() Response Example: diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 94e17c4c..eff5363c 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -90,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 @@ -115,7 +118,7 @@ class WMProjectUserEntity(TimedBaseModel): custom_fields: dict | None = Field(default_factory=dict, alias="customField") permissions: dict | None = None categories: list[dict] | None = None - project_permissions: list[PermissionEntity] | None = Field( + user_permissions: list[PermissionEntity] | None = Field( default=None, alias="userPermissions" ) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 106ee9ed..9707ece4 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -196,9 +196,7 @@ def remove_users_from_project(self, project: ProjectEntity, user_emails: list[st def list_users( self, - include: list[ - Literal["custom_fields", "categories", "project_permissions"] - ] = None, + include: list[Literal["custom_fields", "categories"]] = None, project=None, **filters, ): @@ -246,11 +244,8 @@ def list_users( ) query = chain.handle(filters, EmptyQuery()) - if project and include: - if "categories" in include: - query &= Join("categories") - if "project_permissions" in include: - query &= Join("userPermissions") + if project and include and "categories" in include: + query &= Join("categories") if include and "custom_fields" in include: response = self.service_provider.work_management.list_users( diff --git a/tests/integration/work_management/test_list_users.py b/tests/integration/work_management/test_list_users.py index c1f6a111..a624e914 100644 --- a/tests/integration/work_management/test_list_users.py +++ b/tests/integration/work_management/test_list_users.py @@ -67,18 +67,31 @@ def test_list_users_by_project_ID(self): assert user_1["role"] == "Annotator" assert user_1["email"] == self.scapegoat["email"] - def test_list_users_include_project_permissions(self): + def test_list_users_user_permissions_project_level(self): project_users = sa.list_users( project=self.PROJECT_NAME, email=self.scapegoat["email"], - include=["project_permissions"], ) assert len(project_users) == 1 user = project_users[0] - assert "project_permissions" in user - assert isinstance(user["project_permissions"], list) + assert "user_permissions" in user + assert isinstance(user["user_permissions"], list) - def test_list_users_include_project_permissions_after_grant(self): + 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" @@ -91,10 +104,26 @@ def test_list_users_include_project_permissions_after_grant(self): project_users = sa.list_users( project=self.PROJECT_NAME_2, email=self.scapegoat["email"], - include=["project_permissions"], ) assert len(project_users) == 1 user = project_users[0] - assert isinstance(user.get("project_permissions"), list) - granted_names = {p.get("name") for p in user["project_permissions"]} + 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 From 230571b2c268ff6f6a0b22f283abde456d3e3098 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 25 May 2026 10:52:14 +0400 Subject: [PATCH 23/27] Fix upload_images_to_project log msg --- src/superannotate/lib/app/interface/sdk_interface.py | 5 ++--- src/superannotate/lib/core/usecases/items.py | 4 ++-- src/superannotate/lib/infrastructure/controller.py | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index f96b9c47..b831e3ec 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -4077,9 +4077,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.name}." - ) + 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 diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index b4e44121..2b9cf3b6 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -172,8 +172,8 @@ def __init__( project: ProjectEntity, folder: FolderEntity, service_provider: BaseServiceProvider, - query: str, - subset: str = None, + query: str | None, + subset: str | None = None, ): super().__init__(reporter) self._project = project diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index 9707ece4..c14cd69e 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -2109,8 +2109,8 @@ def query_items_count( self, project: ProjectEntity, folder: FolderEntity, - query: str = None, - subset: str = None, + query: str | None = None, + subset: str | None = None, ) -> int: use_case = usecases.QueryEntitiesCountUseCase( From 462cd90c8249e2048d69ce7ed878dc33274a0985 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 25 May 2026 18:19:13 +0400 Subject: [PATCH 24/27] Fix get project method --- .../lib/app/interface/sdk_interface.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index b831e3ec..1572936e 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -321,7 +321,7 @@ def get_project_by_id(self, project_id: int): :return: project metadata :rtype: dict """ - response = self.controller.get_project(project_id=project_id) + response = self.controller.get_project(project_id) return ProjectSerializer(response.data).serialize() @@ -371,7 +371,7 @@ def get_item_by_id( :return: item metadata :rtype: dict """ - project_response = self.controller.get_project(project_id=project_id) + project_response = self.controller.get_project(project_id) project_response.raise_for_status() if ( @@ -5987,16 +5987,7 @@ def item_context( 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(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." From c40bb4efc62983b1c61d487cc54f85ce6a698043 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 25 May 2026 18:28:12 +0400 Subject: [PATCH 25/27] Fix get project method --- src/superannotate/lib/app/interface/sdk_interface.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 1572936e..b6504dde 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -371,13 +371,12 @@ def get_item_by_id( :return: item metadata :rtype: dict """ - project_response = self.controller.get_project(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." @@ -394,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] From 3885e824c2f1c365375568d6ebfb4e295794fc82 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 25 May 2026 18:33:44 +0400 Subject: [PATCH 26/27] Fix get project method --- src/superannotate/lib/app/interface/sdk_interface.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index b6504dde..3a9387e2 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -321,9 +321,9 @@ def get_project_by_id(self, project_id: int): :return: project metadata :rtype: dict """ - response = self.controller.get_project(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 @@ -1260,7 +1260,7 @@ 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) From 9633d022725839c240ef13742d81da78d8815c04 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 26 May 2026 18:55:56 +0400 Subject: [PATCH 27/27] Update changelog --- CHANGELOG.rst | 15 +++++++++++++++ pytest.ini | 2 +- src/superannotate/__init__.py | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4bdff3f8..0814a37f 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/pytest.ini b/pytest.ini index d9f7f6cc..c0f66b58 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 ebed1f89..937b423f 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.5dev2" +__version__ = "4.5.5dev3" os.environ.update({"sa_version": __version__})