Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/Test_PyPI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,4 @@ debug_*
*.DS_Store
htmlcov
htmlcov
/CLAUDE.md
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

repos:
- repo: 'https://github.com/wimglenn/reorder-python-imports-black'
rev: v3.14.0
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
______________________

Expand Down
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion src/superannotate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os
import sys

__version__ = "4.5.5"
__version__ = "4.5.6"


os.environ.update({"sa_version": __version__})
Expand Down
4 changes: 1 addition & 3 deletions src/superannotate/lib/app/interface/sdk_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,6 @@
"Vector",
"Video",
"Document",
"Tiled",
"PointCloud",
"Multimodal",
]

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/superannotate/lib/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION):
"TemplateState",
"CategorizeItems",
"MaxIdleDuration",
"ItemAutoAssignOrder",
"ImageAutoAssignByCategory",
]

__alL__ = (
Expand Down
15 changes: 4 additions & 11 deletions src/superannotate/lib/core/usecases/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import os
import platform
import re
import time
import traceback
from collections import defaultdict
from collections.abc import Callable
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -876,12 +876,9 @@ 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,
"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
Expand Down Expand Up @@ -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._project.team_id,
"timestamp": int(round(time.time() * 1000)),
}
return annotation
return set_last_action(annotation, self._user.email)

@staticmethod
def serialize_folder_name(val):
Expand Down
10 changes: 10 additions & 0 deletions src/superannotate/lib/core/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/superannotate/lib/infrastructure/annotation_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/aggregations/test_df_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
4 changes: 2 additions & 2 deletions tests/integration/export/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions tests/integration/items/test_attach_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/items/test_generate_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class TestGenerateItemsMM(TestCase):
}

def setUp(self, *args, **kwargs):
self.tearDown()
sa.create_project(
self.PROJECT_NAME,
self.PROJECT_DESCRIPTION,
Expand All @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions tests/integration/items/test_item_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"])
4 changes: 2 additions & 2 deletions tests/integration/mixpanel/test_mixpanel_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions tests/integration/projects/test_basic_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class BaseMultimodalProjectCreate(TestCase):
"readme": "",
}
MULTIMODAL_FORM = None
SETTINGS = None

def setUp(self, *args, **kwargs):
self.tearDown()
Expand All @@ -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,
)

Expand Down
13 changes: 12 additions & 1 deletion tests/integration/projects/test_clone_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion tests/integration/projects/test_create_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading