From d493647cca7d8c6fd57857e9dff77b67943e8331 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 28 May 2026 15:34:23 +0400 Subject: [PATCH 1/7] new project settings --- src/superannotate/lib/core/__init__.py | 2 ++ .../projects/test_basic_project.py | 2 ++ .../projects/test_clone_project.py | 13 ++++++++- tests/integration/settings/test_settings.py | 29 +++++++++++-------- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 77f51b04..3d886672 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -184,6 +184,8 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION): "TemplateState", "CategorizeItems", "MaxIdleDuration", + "ItemAutoAssignOrder", + "ImageAutoAssignByCategory", ] __alL__ = ( diff --git a/tests/integration/projects/test_basic_project.py b/tests/integration/projects/test_basic_project.py index 78fd0cde..0b1910a3 100644 --- a/tests/integration/projects/test_basic_project.py +++ b/tests/integration/projects/test_basic_project.py @@ -34,6 +34,7 @@ class BaseMultimodalProjectCreate(TestCase): "readme": "", } MULTIMODAL_FORM = None + SETTINGS = None def setUp(self, *args, **kwargs): self.tearDown() @@ -48,6 +49,7 @@ def setUp(self, *args, **kwargs): self.PROJECT_NAME, self.PROJECT_DESCRIPTION, self.PROJECT_TYPE, + settings=self.SETTINGS, form=self.MULTIMODAL_FORM, ) diff --git a/tests/integration/projects/test_clone_project.py b/tests/integration/projects/test_clone_project.py index 035c6b40..f1484e80 100644 --- a/tests/integration/projects/test_clone_project.py +++ b/tests/integration/projects/test_clone_project.py @@ -254,8 +254,13 @@ def test_clone_video_project_frame_mode_off(self): class TestCloneMultimodalProject(BaseMultimodalProjectCreate): FORM_PATH = DATA_SET_PATH / "multimodal_form" / "form.json" - PROJECT_NAME = "test_clone_multimodal_project" + PROJECT_NAME = "test_multimodal_project" PROJECT_NAME_CLONE = "test_clone_multimodal_project_clone" + SETTINGS = [ + {"attribute": "ImageAutoAssignByCategory", "value": 1}, + {"attribute": "ItemAutoAssignOrder", "value": 4}, + {"attribute": "ImageAutoAssignCount", "value": 5}, + ] def tearDown(self) -> None: super().tearDown() @@ -294,6 +299,12 @@ def test_clone_multimodal_project(self): for s in cloned_project["settings"]: if s["attribute"] == "TemplateState": assert s["value"] == 1 + if s["attribute"] == "ImageAutoAssignByCategory": + assert s["value"] == 1 + if s["attribute"] == "ItemAutoAssignOrder": + assert s["value"] == 4 + if s["attribute"] == "ImageAutoAssignCount": + assert s["value"] == 5 def test_clone_multimodal_project_copy_settings_false(self): sa.clone_project( diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index 3ce9feb0..8fc9ee14 100644 --- a/tests/integration/settings/test_settings.py +++ b/tests/integration/settings/test_settings.py @@ -16,13 +16,11 @@ def setUp(self) -> None: def tearDown(self) -> None: try: - projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) - projects.extend( - sa.search_projects(self.SECOND_PROJECT_NAME, return_metadata=True) - ) + projects = sa.list_projects(name=self.PROJECT_NAME) + projects.extend(sa.list_projects(name=self.SECOND_PROJECT_NAME)) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project=project["id"]) except Exception: pass except Exception as e: @@ -51,16 +49,19 @@ def test_create_project_with_settings(self): self.PROJECT_NAME, self.PROJECT_DESCRIPTION, self.PROJECT_TYPE, - [{"attribute": "ImageQuality", "value": "original"}], + settings=[ + {"attribute": "ImageQuality", "value": "original"}, + {"attribute": "ItemAutoAssignOrder", "value": 3}, + ], ) settings = sa.get_project_settings(self.PROJECT_NAME) + assert settings for setting in settings: if setting["attribute"] == "ImageQuality": assert setting["value"] == "original" - break - else: - raise Exception("Test failed") + if setting["attribute"] == "ItemAutoAssignOrder": + assert setting["value"] == 3 def test_frame_rate_invalid_range_value(self): with self.assertRaisesRegex( @@ -180,16 +181,20 @@ class TestMMSettings(BaseTestCase): "readme": "", } - def test_frame_rate(self): + def test_mm_project_specific_settings(self): sa.create_project( self.PROJECT_NAME, self.PROJECT_DESCRIPTION, self.PROJECT_TYPE, - settings=[{"attribute": "MaxIdleDuration", "value": 612}], + settings=[ + {"attribute": "MaxIdleDuration", "value": 612}, + {"attribute": "ImageAutoAssignByCategory", "value": 1}, + ], 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 + if setting["attribute"] == "ImageAutoAssignByCategory": + assert setting["value"] == 1 From 355e2fbffffee7de41d6a79dd94d0791e48b681d Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 28 May 2026 18:16:14 +0400 Subject: [PATCH 2/7] depricate PointCloud/Tiled fix tests --- .../lib/app/interface/sdk_interface.py | 4 +--- .../aggregations/test_df_processing.py | 3 ++- tests/integration/export/test_export.py | 4 ++-- .../integration/items/test_attach_category.py | 6 +++--- .../integration/items/test_generate_items.py | 5 +++-- .../mixpanel/test_mixpanel_decorator.py | 4 ++-- .../projects/test_create_project.py | 9 ++++++++- .../projects/test_project_rename.py | 15 +++++++------- .../projects/test_search_project.py | 18 ++++++++--------- tests/integration/settings/test_settings.py | 20 ++++++++----------- tests/integration/test_cli.py | 7 +++++-- .../test_depricated_functions_document.py | 11 +++++----- .../test_depricated_functions_video.py | 8 +++++--- tests/integration/test_video.py | 12 +++++++---- .../tiled_project/test_tiled_project.py | 14 ------------- .../test_contributors_categories.py | 6 +++--- .../test_project_categories.py | 6 +++--- .../test_project_user_permissions.py | 6 +++--- .../work_management/test_user_scoring.py | 6 +++--- 19 files changed, 80 insertions(+), 84 deletions(-) delete mode 100644 tests/integration/tiled_project/test_tiled_project.py diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 3a9387e2..ee940303 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -96,8 +96,6 @@ "Vector", "Video", "Document", - "Tiled", - "PointCloud", "Multimodal", ] @@ -1409,7 +1407,7 @@ def create_project( :param project_description: The new project's description. :type project_description: str - :param project_type: The project type. Supported types: 'Vector', 'Video', 'Document', 'Tiled', 'PointCloud', 'Multimodal'. + :param project_type: The project type. Supported types: 'Vector', 'Video', 'Document', 'Multimodal'. :type project_type: str :param settings: list of settings objects diff --git a/tests/integration/aggregations/test_df_processing.py b/tests/integration/aggregations/test_df_processing.py index 321b1379..b401351d 100644 --- a/tests/integration/aggregations/test_df_processing.py +++ b/tests/integration/aggregations/test_df_processing.py @@ -30,7 +30,8 @@ def test_filter_instances(self): def test_invalid_project_type(self): with self.assertRaisesRegex( - AppException, "The function is not supported for PointCloud projects." + AppException, + "Input should be 'Vector', 'Video', 'Document' or 'Multimodal'", ): sa.aggregate_annotations_as_df(self.folder_path, "PointCloud") diff --git a/tests/integration/export/test_export.py b/tests/integration/export/test_export.py index b97c844e..25ddb215 100644 --- a/tests/integration/export/test_export.py +++ b/tests/integration/export/test_export.py @@ -65,10 +65,10 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: - projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name=cls.PROJECT_NAME) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project=project["id"]) except AppException as e: logging.error(e) diff --git a/tests/integration/items/test_attach_category.py b/tests/integration/items/test_attach_category.py index 4515be1c..965be340 100644 --- a/tests/integration/items/test_attach_category.py +++ b/tests/integration/items/test_attach_category.py @@ -66,11 +66,11 @@ def setUpClass(cls, *args, **kwargs) -> None: @classmethod def tearDownClass(cls) -> None: # cleanup test scores and project - projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[cls.PROJECT_NAME]) for project in projects: try: - sa.delete_project(project) - except Exception: + sa.delete_project(project=project["id"]) + except Exception as _: pass @staticmethod diff --git a/tests/integration/items/test_generate_items.py b/tests/integration/items/test_generate_items.py index 9f895bab..352c80e8 100644 --- a/tests/integration/items/test_generate_items.py +++ b/tests/integration/items/test_generate_items.py @@ -27,6 +27,7 @@ class TestGenerateItemsMM(TestCase): } def setUp(self, *args, **kwargs): + self.tearDown() sa.create_project( self.PROJECT_NAME, self.PROJECT_DESCRIPTION, @@ -37,10 +38,10 @@ def setUp(self, *args, **kwargs): def tearDown(self) -> None: try: - projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name=self.PROJECT_NAME) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project=project["id"]) except Exception as e: print(str(e)) except Exception as e: diff --git a/tests/integration/mixpanel/test_mixpanel_decorator.py b/tests/integration/mixpanel/test_mixpanel_decorator.py index 52f96614..855bf3c4 100644 --- a/tests/integration/mixpanel/test_mixpanel_decorator.py +++ b/tests/integration/mixpanel/test_mixpanel_decorator.py @@ -44,10 +44,10 @@ def tearDownClass(cls) -> None: @classmethod def _safe_delete_project(cls, project_name): - projects = sa.search_projects(project_name, return_metadata=True) + projects = sa.list_projects(name=project_name) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project=project["id"]) except Exception: raise diff --git a/tests/integration/projects/test_create_project.py b/tests/integration/projects/test_create_project.py index ef5e604b..5dcf04a6 100644 --- a/tests/integration/projects/test_create_project.py +++ b/tests/integration/projects/test_create_project.py @@ -95,7 +95,7 @@ def test_create_project_datetime(self): def test_create_project_with_wrong_type(self): with self.assertRaisesRegex( AppException, - "Input should be 'Vector', 'Video', 'Document', 'Tiled', 'PointCloud' or 'Multimodal'", + "Input should be 'Vector', 'Video', 'Document' or 'Multimodal'", ): sa.create_project(self.PROJECT, "desc", "wrong_type") @@ -130,6 +130,13 @@ def test_create_project_with_classes_and_workflows(self): assert steps[1]["attribute"][0]["attribute"]["name"] == "Track" assert steps[1]["attribute"][1]["attribute"]["name"] == "Bus" + def test_deprecated_project_crate(self): + error_msg = "Input should be 'Vector', 'Video', 'Document' or 'Multimodal'" + with self.assertRaisesRegex(AppException, error_msg): + sa.create_project(self.PROJECT, "desc", "Tiled") + with self.assertRaisesRegex(AppException, error_msg): + sa.create_project(self.PROJECT, "desc", "PointCloud") + class TestCreateVideoProject(ProjectCreateBaseTestCase): PROJECT = "test_video_project" diff --git a/tests/integration/projects/test_project_rename.py b/tests/integration/projects/test_project_rename.py index d63eebed..1a58068a 100644 --- a/tests/integration/projects/test_project_rename.py +++ b/tests/integration/projects/test_project_rename.py @@ -20,16 +20,17 @@ def setUp(self, *args, **kwargs): ) def tearDown(self) -> None: - projects = [] - projects.extend(sa.search_projects(self.PROJECT_NAME, return_metadata=True)) - projects.extend(sa.search_projects(self.NEW_PROJECT_NAME, return_metadata=True)) - projects.extend( - sa.search_projects(self.REPLACED_PROJECT_NAME, return_metadata=True) + projects = sa.list_projects( + name__in=[ + self.PROJECT_NAME, + self.NEW_PROJECT_NAME, + self.REPLACED_PROJECT_NAME, + self.NAME_TO_RENAME, + ] ) - projects.extend(sa.search_projects(self.NAME_TO_RENAME, return_metadata=True)) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project=project["id"]) except Exception as _: pass diff --git a/tests/integration/projects/test_search_project.py b/tests/integration/projects/test_search_project.py index f9673606..3a903633 100644 --- a/tests/integration/projects/test_search_project.py +++ b/tests/integration/projects/test_search_project.py @@ -22,19 +22,17 @@ def setUp(self, *args, **kwargs): ) def tearDown(self) -> None: - projects = [] - projects.extend(sa.search_projects(self.PROJECT_NAME, return_metadata=True)) - projects.extend( - sa.search_projects(self.REPLACED_PROJECT_NAME, return_metadata=True) - ) - projects.extend( - sa.search_projects( - self.PROJECT_NAME_CONTAIN_SPECIAL_CHARACTER, return_metadata=True - ) + projects = sa.list_projects( + name__in=[ + self.PROJECT_NAME, + self.PROJECT_NAME_2, + self.REPLACED_PROJECT_NAME, + self.PROJECT_NAME_CONTAIN_SPECIAL_CHARACTER, + ] ) for project in projects: try: - sa.delete_project(project) + sa.delete_project(project=project["id"]) except Exception as _: pass diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index 3ce9feb0..33bb3d47 100644 --- a/tests/integration/settings/test_settings.py +++ b/tests/integration/settings/test_settings.py @@ -15,18 +15,14 @@ def setUp(self) -> None: self.tearDown() def tearDown(self) -> None: - try: - projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) - projects.extend( - sa.search_projects(self.SECOND_PROJECT_NAME, return_metadata=True) - ) - for project in projects: - try: - sa.delete_project(project) - except Exception: - pass - except Exception as e: - print(str(e)) + projects = sa.list_projects( + name__in=[self.PROJECT_NAME, self.SECOND_PROJECT_NAME] + ) + for project in projects: + try: + sa.delete_project(project=project["id"]) + except Exception as _: + pass class TestSettings(BaseTestCase): diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index f95fd8e7..55dc8ab7 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -37,9 +37,12 @@ def setUp(self, *args, **kwargs): self.tearDown() def tearDown(self) -> None: - projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[self.PROJECT_NAME]) for project in projects: - sa.delete_project(project) + try: + sa.delete_project(project=project["id"]) + except Exception as _: + pass @property def convertor_data_path(self): diff --git a/tests/integration/test_depricated_functions_document.py b/tests/integration/test_depricated_functions_document.py index f61c603e..46bc0d0c 100644 --- a/tests/integration/test_depricated_functions_document.py +++ b/tests/integration/test_depricated_functions_document.py @@ -40,13 +40,12 @@ def setUp(self, *args, **kwargs): ) def tearDown(self) -> None: - projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[self.PROJECT_NAME, self.PROJECT_NAME_2]) for project in projects: - sa.delete_project(project) - - projects = sa.search_projects(self.PROJECT_NAME_2, return_metadata=True) - for project in projects: - sa.delete_project(project) + try: + sa.delete_project(project=project["id"]) + except Exception as _: + pass @property def video_export_path(self): diff --git a/tests/integration/test_depricated_functions_video.py b/tests/integration/test_depricated_functions_video.py index a60926f2..02fb3f80 100644 --- a/tests/integration/test_depricated_functions_video.py +++ b/tests/integration/test_depricated_functions_video.py @@ -38,10 +38,12 @@ def setUp(self, *args, **kwargs): ) def tearDown(self) -> None: - projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) - projects.extend(sa.search_projects(self.PROJECT_NAME_2, return_metadata=True)) + projects = sa.list_projects(name__in=[self.PROJECT_NAME, self.PROJECT_NAME_2]) for project in projects: - sa.delete_project(project) + try: + sa.delete_project(project=project["id"]) + except Exception as _: + pass @property def video_export_path(self): diff --git a/tests/integration/test_video.py b/tests/integration/test_video.py index e47000a0..6ff1e0bf 100644 --- a/tests/integration/test_video.py +++ b/tests/integration/test_video.py @@ -45,10 +45,14 @@ def setUp(self, *args, **kwargs): ) def tearDown(self) -> None: - for project_name in (self.PROJECT_NAME, self.SECOND_PROJECT_NAME): - projects = sa.search_projects(project_name, return_metadata=True) - for project in projects: - sa.delete_project(project) + projects = sa.list_projects( + name__in=[self.PROJECT_NAME, self.SECOND_PROJECT_NAME] + ) + for project in projects: + try: + sa.delete_project(project=project["id"]) + except Exception as _: + pass def test_videos_upload_from_folder(self): res = sa.upload_videos_from_folder_to_project( diff --git a/tests/integration/tiled_project/test_tiled_project.py b/tests/integration/tiled_project/test_tiled_project.py deleted file mode 100644 index 12517fa9..00000000 --- a/tests/integration/tiled_project/test_tiled_project.py +++ /dev/null @@ -1,14 +0,0 @@ -from src.superannotate import SAClient -from tests.integration.base import BaseTestCase - -sa = SAClient() - - -class TestTiledProject(BaseTestCase): - PROJECT_NAME = "TestGetIntegrations" - PROJECT_DESCRIPTION = "desc" - PROJECT_TYPE = "Tiled" - - def test_get_metadata(self): - self._attach_items() - assert all(i["metadata"]["name"] for i in sa.get_annotations(self.PROJECT_NAME)) diff --git a/tests/integration/work_management/test_contributors_categories.py b/tests/integration/work_management/test_contributors_categories.py index fdeb82a9..313ef841 100644 --- a/tests/integration/work_management/test_contributors_categories.py +++ b/tests/integration/work_management/test_contributors_categories.py @@ -74,11 +74,11 @@ def setUpClass(cls, *args, **kwargs) -> None: @classmethod def tearDownClass(cls) -> None: - projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[cls.PROJECT_NAME]) for project in projects: try: - sa.delete_project(project) - except Exception: + sa.delete_project(project=project["id"]) + except Exception as _: pass def tearDown(self): diff --git a/tests/integration/work_management/test_project_categories.py b/tests/integration/work_management/test_project_categories.py index 1d873de7..7b86b745 100644 --- a/tests/integration/work_management/test_project_categories.py +++ b/tests/integration/work_management/test_project_categories.py @@ -67,11 +67,11 @@ def setUpClass(cls, *args, **kwargs) -> None: @classmethod def tearDownClass(cls) -> None: # cleanup test scores and project - projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[cls.PROJECT_NAME]) for project in projects: try: - sa.delete_project(project) - except Exception: + sa.delete_project(project=project["id"]) + except Exception as _: pass def test_project_categories_flow(self): diff --git a/tests/integration/work_management/test_project_user_permissions.py b/tests/integration/work_management/test_project_user_permissions.py index ed0f044a..0a3593db 100644 --- a/tests/integration/work_management/test_project_user_permissions.py +++ b/tests/integration/work_management/test_project_user_permissions.py @@ -29,11 +29,11 @@ def setUpClass(cls, *args, **kwargs) -> None: @classmethod def tearDownClass(cls) -> None: - projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[cls.PROJECT_NAME]) for project in projects: try: - sa.delete_project(project) - except Exception: + sa.delete_project(project=project["id"]) + except Exception as _: pass def tearDown(self): diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index fee2a237..3363d44e 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -91,11 +91,11 @@ def setUpClass(cls, *args, **kwargs) -> None: @classmethod def tearDownClass(cls) -> None: # cleanup test scores and project - projects = sa.search_projects(cls.PROJECT_NAME, return_metadata=True) + projects = sa.list_projects(name__in=[cls.PROJECT_NAME]) for project in projects: try: - sa.delete_project(project) - except Exception: + sa.delete_project(project=project["id"]) + except Exception as _: pass score_templates_name_id_map = { From f57fa26c607b22bf828b62e84ccd984a1a49661e Mon Sep 17 00:00:00 2001 From: "Dr. Q and Company" <213266729+drQedwards@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:25:49 -0400 Subject: [PATCH 3/7] Update GitHub Actions to use ubuntu-latest --- .github/workflows/Test_PyPI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Test_PyPI.yml b/.github/workflows/Test_PyPI.yml index cc69a230..8394961f 100644 --- a/.github/workflows/Test_PyPI.yml +++ b/.github/workflows/Test_PyPI.yml @@ -8,7 +8,7 @@ on: jobs: build-n-publish: name: Build and publish Python 🐍 distributions 📦 to TestPyPI - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 From 8c03a7442c40bcd0cce7c13fedeb4b904d65c3f0 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 12 Jun 2026 17:09:56 +0400 Subject: [PATCH 4/7] Fix upload annotations last action --- .pre-commit-config.yaml | 1 + pytest.ini | 2 +- src/superannotate/lib/core/usecases/annotations.py | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0015d7c3..80ccc663 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ + repos: - repo: 'https://github.com/wimglenn/reorder-python-imports-black' rev: v3.14.0 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/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 85801320..257e6024 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -876,10 +876,10 @@ def _validate_json(self, json_data: dict) -> list: return use_case.execute().data @staticmethod - def set_defaults(team_id, annotation_data: dict, project_type: int): + def set_defaults(user_email, annotation_data: dict, project_type: int): default_data = {} annotation_data["metadata"]["lastAction"] = { - "email": team_id, + "email": user_email, "timestamp": int(round(time.time() * 1000)), } instances = annotation_data.get("instances", []) @@ -1909,7 +1909,7 @@ def attach_items( def set_defaults(self, annotation: dict): annotation["metadata"]["lastAction"] = { - "email": self._project.team_id, + "email": self._user.email, "timestamp": int(round(time.time() * 1000)), } return annotation From ae450239da601a95b0d2073118d0babe48e3b2c9 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 17 Jun 2026 18:43:47 +0400 Subject: [PATCH 5/7] add lastAction in item_context --- .gitignore | 1 + .../lib/core/usecases/annotations.py | 13 ++--- src/superannotate/lib/core/utils.py | 10 ++++ .../lib/infrastructure/annotation_adapter.py | 7 +++ tests/integration/items/test_item_context.py | 52 +++++++++++++++++++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 732995c9..0e32d89e 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ debug_* *.DS_Store htmlcov htmlcov +/CLAUDE.md diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 257e6024..3b5d6f48 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -9,7 +9,6 @@ import os import platform import re -import time import traceback from collections import defaultdict from collections.abc import Callable @@ -47,6 +46,7 @@ from lib.core.usecases.folders import CreateFolderUseCase from lib.core.usecases.items import AttachItems from lib.core.utils import run_async +from lib.core.utils import set_last_action from lib.core.video_convertor import VideoFrameGenerator from lib.infrastructure.utils import divide_to_chunks from pydantic import BaseModel @@ -878,10 +878,7 @@ def _validate_json(self, json_data: dict) -> list: @staticmethod def set_defaults(user_email, annotation_data: dict, project_type: int): default_data = {} - annotation_data["metadata"]["lastAction"] = { - "email": user_email, - "timestamp": int(round(time.time() * 1000)), - } + set_last_action(annotation_data, user_email) instances = annotation_data.get("instances", []) if project_type in constants.ProjectType.images: default_data["probability"] = 100 @@ -1908,11 +1905,7 @@ def attach_items( return self.list_items(folder, attached_item_names) def set_defaults(self, annotation: dict): - annotation["metadata"]["lastAction"] = { - "email": self._user.email, - "timestamp": int(round(time.time() * 1000)), - } - return annotation + return set_last_action(annotation, self._user.email) @staticmethod def serialize_folder_name(val): diff --git a/src/superannotate/lib/core/utils.py b/src/superannotate/lib/core/utils.py index 9c53f1b1..4a7fc155 100644 --- a/src/superannotate/lib/core/utils.py +++ b/src/superannotate/lib/core/utils.py @@ -1,9 +1,19 @@ import asyncio import re +import time import typing from threading import Thread +def set_last_action(annotation: dict, email: str) -> dict: + """Set ``metadata.lastAction`` on an annotation JSON in place.""" + annotation.setdefault("metadata", {})["lastAction"] = { + "email": email, + "timestamp": int(round(time.time() * 1000)), + } + return annotation + + class AsyncThread(Thread): def __init__( self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None diff --git a/src/superannotate/lib/infrastructure/annotation_adapter.py b/src/superannotate/lib/infrastructure/annotation_adapter.py index 949a2a2b..2ef38ce7 100644 --- a/src/superannotate/lib/infrastructure/annotation_adapter.py +++ b/src/superannotate/lib/infrastructure/annotation_adapter.py @@ -8,6 +8,7 @@ from lib.core.entities import FolderEntity from lib.core.entities import ProjectEntity from lib.core.utils import run_async +from lib.core.utils import set_last_action from lib.infrastructure.controller import Controller @@ -57,6 +58,10 @@ def get_component_value(self, component_id: str): return annotation.get("value") return None + def set_last_action(self): + """Stamp ``metadata.lastAction`` with the current user, called on save.""" + set_last_action(self.annotation, self._controller.current_user.email) + def set_component_value(self, component_id: str, value: Any): data = self.annotation.setdefault("data", {}) component_data = data.get(component_id) @@ -116,6 +121,7 @@ def annotation(self) -> dict: return self._annotation def save(self): + self.set_last_action() self._controller.annotations.set_item_annotations( project=self._project, folder=self._folder, @@ -142,6 +148,7 @@ def annotation(self) -> dict: return self._annotation def save(self): + self.set_last_action() run_async( self._controller.service_provider.annotations.upload_big_annotation( project=self._project, diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 7bcd7ede..bf30b138 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -8,6 +8,9 @@ from src.superannotate import FileChangedError from src.superannotate import ItemContext from src.superannotate import SAClient +from src.superannotate.lib.infrastructure.annotation_adapter import ( + MultimodalSmallAnnotationAdapter, +) from tests.integration.base import BaseTestCase sa = SAClient() @@ -105,6 +108,20 @@ def test_overwrite_false(self): item = sa.search_items(f"{self.PROJECT_NAME}/folder", "dummy")[0] self._base_test((self._project["id"], folder["id"]), item["id"]) + def test_set_component_value_stamps_last_action(self): + user_email = sa.controller.current_user.email + self._attach_item(self.PROJECT_NAME, "audit_item") + + with sa.item_context(self.PROJECT_NAME, "audit_item", overwrite=True) as ic: + ic.set_component_value("component_id_1", "value") + + # re-fetch through the SDK to verify the metadata was persisted server-side + with sa.item_context(self.PROJECT_NAME, "audit_item") as ic: + assert ic.get_component_value("component_id_1") == "value" + last_action = ic.get_metadata()["lastAction"] + assert last_action["email"] == user_email + assert isinstance(last_action["timestamp"], int) + class TestEditorContext(BaseTestCase): PROJECT_NAME = "TestEditorContext" @@ -199,3 +216,38 @@ def test_save_not_called_when_exception_raised(self): ic.set_component_value("component_id", "value") raise RuntimeError("boom") save_mock.assert_not_called() + + +class TestLastActionMetadata(TestCase): + EMAIL = "narekm@superannotate.com" + + def _make_adapter(self, annotation): + controller = MagicMock() + controller.current_user.email = self.EMAIL + return MultimodalSmallAnnotationAdapter( + project=MagicMock(), + folder=MagicMock(), + item=MagicMock(), + controller=controller, + annotation=annotation, + ) + + def test_set_component_value_does_not_add_audit_fields(self): + adapter = self._make_adapter({"metadata": {}, "data": {}}) + adapter.set_component_value("component_id_1", "value") + element = adapter.annotation["data"]["component_id_1"][0] + self.assertEqual(element, {"value": "value"}) + + def test_save_stamps_last_action(self): + adapter = self._make_adapter({"metadata": {}, "data": {}}) + adapter.save() + last_action = adapter.annotation["metadata"]["lastAction"] + self.assertEqual(last_action["email"], self.EMAIL) + self.assertIsInstance(last_action["timestamp"], int) + adapter._controller.annotations.set_item_annotations.assert_called_once() + + def test_save_preserves_existing_metadata(self): + adapter = self._make_adapter({"metadata": {"name": "item"}, "data": {}}) + adapter.save() + self.assertEqual(adapter.annotation["metadata"]["name"], "item") + self.assertIn("lastAction", adapter.annotation["metadata"]) From fac3b87cbfb8748b683dd62ce92516ec04938e0f Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 26 Jun 2026 18:23:23 +0400 Subject: [PATCH 6/7] Update changelog --- CHANGELOG.rst | 8 ++++++++ src/superannotate/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0814a37f..58d797bc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,14 @@ History All release highlights of this project will be documented in this file. +4.5.6 - July 5, 2026 +______________________ + +**Updated** + + - ``SAClient.create_project()`` Now support the ImageAutoAssignCount, ItemAutoAssignOrder , ImageAutoAssignByCategory settings. + + 4.5.5 - May 31, 2026 ______________________ diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index c1346ca9..bc903a88 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.5" +__version__ = "4.5.6dev1" os.environ.update({"sa_version": __version__}) From f63828ae773e9501138b3cabd09129d13727c22b Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:43:51 +0200 Subject: [PATCH 7/7] Update __init__.py --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index bc903a88..1eda490f 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.6dev1" +__version__ = "4.5.6" os.environ.update({"sa_version": __version__})