From e1f18615abf830f862f4589639cab06867b6e37a Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 2 Mar 2026 11:23:27 +0400 Subject: [PATCH 01/15] Update dependencies --- pytest.ini | 2 +- requirements.txt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 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/requirements.txt b/requirements.txt index d8a23ef1..a5c281cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ pydantic>=1.10,<3,!=2.0.* aiohttp~=3.8 -boto3~=1.26 +boto3~=1.42 opencv-python-headless~=4.7 -plotly~=5.14 +plotly~=6.5 pandas~=2.0 -pillow>=9.5,~=10.0 +pillow~=12.1 tqdm~=4.66 -requests==2.* -aiofiles==23.* -fire==0.4.0 -mixpanel==4.8.3 +requests~=2.32 +aiofiles~=25.1 +fire~=0.7 +mixpanel~=5.1 superannotate-schemas==1.0.49 \ No newline at end of file From 50b4c6070b9010166d9ac3204b65aab77694d55d Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 2 Mar 2026 11:30:08 +0400 Subject: [PATCH 02/15] Update dependecies --- requirements.txt | 4 ++-- src/superannotate/lib/core/entities/classes.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index b88d8285..57d30220 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -pydantic>=2.5,<3 -pydantic-extra-types>=2.0 +pydantic~=2.5 +pydantic-extra-types~=2.11 aiohttp~=3.8 boto3~=1.26 opencv-python-headless~=4.7 diff --git a/src/superannotate/lib/core/entities/classes.py b/src/superannotate/lib/core/entities/classes.py index ec6c32b3..4c125918 100644 --- a/src/superannotate/lib/core/entities/classes.py +++ b/src/superannotate/lib/core/entities/classes.py @@ -16,7 +16,6 @@ from pydantic.functional_validators import BeforeValidator from pydantic_extra_types.color import Color -from lib.core.enums import BaseTitledEnum from lib.core.enums import ClassTypeEnum DATE_REGEX = r"\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d{3})Z" From 7521795f4158132c3115fc09235cd47a24530128 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 4 Mar 2026 16:35:28 +0400 Subject: [PATCH 03/15] Update version --- 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 ab4f820e..e36320e3 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.2" +__version__ = "4.5.3dev1" os.environ.update({"sa_version": __version__}) From c06c06ecbea472d34126e59ddf4a45ebc8b90641 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 12:48:23 +0400 Subject: [PATCH 04/15] Update argument validation --- pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 9 +++- src/superannotate/lib/core/entities/base.py | 16 ++++++- .../lib/core/entities/work_managament.py | 2 +- .../lib/infrastructure/validators.py | 48 +++++++++++++++++-- .../folders/test_delete_folders.py | 2 +- tests/integration/items/test_attach_items.py | 12 ++--- .../items/test_set_approval_statuses.py | 2 +- .../projects/test_create_project.py | 2 +- .../projects/test_set_project_status.py | 2 +- tests/integration/test_cli.py | 6 +-- 11 files changed, 83 insertions(+), 20 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 33b00fb4..33d95dd8 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2105,7 +2105,10 @@ def update_annotation_class( """ project = self.controller.get_project(project) - + if project.type == ProjectType.MULTIMODAL: + raise AppException( + "This function is not supported for Multimodal projects." + ) # Find the annotation class by nam annotation_classes = self.controller.annotation_classes.list( condition=Condition("project_id", project.id, EQ) @@ -3006,6 +3009,10 @@ def create_annotation_class( except ValidationError as e: raise AppException(wrap_error(e)) project = self.controller.get_project(project) + if project.type == ProjectType.MULTIMODAL: + raise AppException( + "This function is not supported for Multimodal projects." + ) if ( project.type != ProjectType.DOCUMENT and annotation_class.type == ClassTypeEnum.RELATIONSHIP diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 9b90f94c..8a989e33 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -11,6 +11,7 @@ from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field +from pydantic import PlainSerializer from pydantic_extra_types.color import Color DATE_TIME_FORMAT_ERROR_MESSAGE = ( @@ -37,7 +38,20 @@ def _validate_string_date(v: datetime) -> str: return v.isoformat().split("+")[0] + ".000Z" -StringDate = Annotated[datetime, AfterValidator(_validate_string_date)] +def _serialize_string_date(v) -> str: + """Serialize datetime or string to string format.""" + if isinstance(v, str): + return v + if isinstance(v, datetime): + return v.isoformat().split("+")[0] + ".000Z" + return v + + +StringDate = Annotated[ + datetime, + AfterValidator(_validate_string_date), + PlainSerializer(_serialize_string_date, return_type=str), +] class SubSetEntity(BaseModel): diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 95b09226..7fd742c5 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -205,7 +205,7 @@ class WMAttributeGroup(TimedBaseModel): model_config = ConfigDict(extra="ignore") id: Optional[StrictInt] = None - group_type: Optional[WMGroupTypeEnum] = None + group_type: WMGroupTypeEnum class_id: Optional[StrictInt] = None name: Optional[StrictStr] = None isRequired: bool = Field(default=False, alias="is_required") diff --git a/src/superannotate/lib/infrastructure/validators.py b/src/superannotate/lib/infrastructure/validators.py index aa6ecc44..17913a9d 100644 --- a/src/superannotate/lib/infrastructure/validators.py +++ b/src/superannotate/lib/infrastructure/validators.py @@ -65,13 +65,55 @@ def get_tabulation() -> int: return 48 +def _is_pydantic_internal_loc(loc_part: typing.Any) -> bool: + """ + Check if a location part is a Pydantic v2 internal type descriptor. + These are not human-readable and should be filtered out. + Examples of internal descriptors: + - 'constrained-str' + - 'lax-or-strict[...]' + - 'list[SomeModel]' + - 'json-or-python[...]' + - 'function-after[...]' + - 'union[...]' + """ + if not isinstance(loc_part, str): + return False + # These patterns indicate internal Pydantic type descriptors + internal_patterns = ( + "constrained-", + "lax-or-strict[", + "json-or-python[", + "function-after[", + "function-before[", + "function-wrap[", + "union[", + "is-instance[", + ) + if loc_part.startswith(internal_patterns): + return True + # Also filter out patterns like 'list[Model]' or 'dict[str, Model]' + # but keep simple field names + if "[" in loc_part and "]" in loc_part: + # Check if it looks like a type descriptor (e.g., 'list[Attachment]') + # rather than a field name + return True + return False + + def wrap_error(e: ValidationError) -> str: tabulation = get_tabulation() error_messages = defaultdict(list) for error in e.errors(): - errors_list = ( - list(error["loc"])[:-1] if len(error["loc"]) > 1 else list(error["loc"]) - ) + error_loc = list(error["loc"]) + # Filter out Pydantic v2 internal type descriptors + error_loc = [loc for loc in error_loc if not _is_pydantic_internal_loc(loc)] + if len(error_loc) == 0: + continue + if len(error_loc) == 1 and isinstance(error_loc[0], int): + errors_list = [f"argument at index {error_loc[0]}"] + else: + errors_list = list(error_loc) if "__root__" in errors_list: errors_list.remove("__root__") errors_list[1::] = [ diff --git a/tests/integration/folders/test_delete_folders.py b/tests/integration/folders/test_delete_folders.py index 5967467b..edd85d13 100644 --- a/tests/integration/folders/test_delete_folders.py +++ b/tests/integration/folders/test_delete_folders.py @@ -33,7 +33,7 @@ def test_search_folders(self): with self.assertRaisesRegex(AppException, "There is no folder to delete."): sa.delete_folders(self.PROJECT_NAME, []) - pattern = r"(\s+)folder_names(\s+)Input should be a valid list" + pattern = r"(\s+)argument at index 2(\s+)Input should be a valid list" with self.assertRaisesRegex(AppException, pattern): sa.delete_folders(self.PROJECT_NAME, None) # noqa diff --git a/tests/integration/items/test_attach_items.py b/tests/integration/items/test_attach_items.py index 7ca0f902..3847c644 100644 --- a/tests/integration/items/test_attach_items.py +++ b/tests/integration/items/test_attach_items.py @@ -117,11 +117,11 @@ class TestAttachItemsVectorArguments(TestCase): def test_attach_items_invalid_payload(self): error_msg = [ "attachments", - "str type expected", - "value is not a valid path", - r"attachments\[0].url", - "field required", + "Input should be a valid string", + "Input is not a valid path", + r"attachments\[0\]\.url", + "Field required", ] - pattern = r"(\s+)" + r"(\s+)".join(error_msg) + pattern = r"[\s\S]+" + r"[\s\S]+".join(error_msg) with self.assertRaisesRegex(AppException, pattern): - sa.attach_items(self.PROJECT_NAME, [{"name": "name"}]) + sa.attach_items(self.PROJECT_NAME, attachments=[{"name": "name"}]) diff --git a/tests/integration/items/test_set_approval_statuses.py b/tests/integration/items/test_set_approval_statuses.py index a257dbc3..15324146 100644 --- a/tests/integration/items/test_set_approval_statuses.py +++ b/tests/integration/items/test_set_approval_statuses.py @@ -81,7 +81,7 @@ def test_set_approval_statuses(self): def test_set_invalid_approval_statuses(self): sa.attach_items(self.PROJECT_NAME, [ATTACHMENT_LIST[0]]) with self.assertRaisesRegex( - AppException, "Available values are 'Approved', 'Disapproved'." + AppException, "Input should be 'Approved', 'Disapproved' or None" ): sa.set_approval_statuses( self.PROJECT_NAME, diff --git a/tests/integration/projects/test_create_project.py b/tests/integration/projects/test_create_project.py index 7550bef5..d301a892 100644 --- a/tests/integration/projects/test_create_project.py +++ b/tests/integration/projects/test_create_project.py @@ -90,7 +90,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', 'Multimodal'", + "Input should be 'Vector', 'Video', 'Document', 'Tiled', 'PointCloud' or 'Multimodal'", ): sa.create_project(self.PROJECT, "desc", "wrong_type") diff --git a/tests/integration/projects/test_set_project_status.py b/tests/integration/projects/test_set_project_status.py index 6d87dc45..77b1260e 100644 --- a/tests/integration/projects/test_set_project_status.py +++ b/tests/integration/projects/test_set_project_status.py @@ -51,7 +51,7 @@ def test_set_project_status_fail(self, update_function): def test_set_project_status_via_invalid_status(self): with self.assertRaisesRegex( AppException, - "Available values are 'NotStarted', 'InProgress', 'Completed', 'OnHold'.", + "Input should be 'NotStarted', 'InProgress', 'Completed' or 'OnHold'", ): sa.set_project_status(project=self.PROJECT_NAME, status="InvalidStatus") diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 85b3139e..f95fd8e7 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -175,21 +175,21 @@ def test_attach_video_urls(self): self.safe_run( self._cli.attach_video_urls, self.PROJECT_NAME, str(self.video_csv_path) ) - self.assertEqual(3, len(sa.search_items(self.PROJECT_NAME))) + self.assertEqual(3, len(sa.list_items(self.PROJECT_NAME))) def test_upload_videos(self): self._create_project() self.safe_run( self._cli.upload_videos, self.PROJECT_NAME, str(self.video_folder_path) ) - self.assertEqual(121, len(sa.search_items(self.PROJECT_NAME))) + self.assertEqual(121, len(sa.list_items(self.PROJECT_NAME))) def test_attach_document_urls(self): self._create_project("Document") self.safe_run( self._cli.attach_document_urls, self.PROJECT_NAME, str(self.video_csv_path) ) - self.assertEqual(3, len(sa.search_items(self.PROJECT_NAME))) + self.assertEqual(3, len(sa.list_items(self.PROJECT_NAME))) def test_init(self): _token = "asd=123" From c55f697d3d9512692a08b4a6df5fa261145d9a8a Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 14:35:17 +0400 Subject: [PATCH 05/15] Fix create_project method --- pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 100 ++++++++++++++---- 2 files changed, 78 insertions(+), 24 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 33d95dd8..ef75bd43 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1187,9 +1187,9 @@ def create_project( settings: List[Setting] = None, classes: List[AnnotationClassEntity] = None, workflows: Any = None, - instructions_link: str = None, - workflow: str = None, - form: dict = None, + instructions_link: Optional[str] = None, + workflow: Optional[str] = None, + form: Optional[dict] = None, ): """Creates a new project in the team. For Multimodal projects, you must provide a valid form object, which serves as a template determining the layout and behavior of the project's interface. @@ -2054,15 +2054,18 @@ def update_annotation_class( name: NotEmptyStr, attribute_groups: List[dict], ): - """Updates an existing annotation class by submitting a full, updated attribute_groups payload. - You can add new attribute groups, add new attribute values, rename attribute groups, rename attribute values, - delete attribute groups, delete attribute values, update attribute group types, update default attributes, - and update the required state. + """ + Updates an existing annotation class by submitting a full, updated attribute_groups payload. You can add new attribute groups, add new attribute values, rename attribute groups, rename attribute values, delete attribute groups, delete attribute values, update attribute group types, update default attributes, and update the required state. + This function does not support Multimodal projects. + + Warning: + Use update_annotation_class() With Extreme Caution + The update_annotation_class() method replaces the entire attribute group structure of the annotation class. + Any attribute group or attribute group ID not included in the payload will be permanently deleted. + Any attribute value or attribute ID not included in the payload will be permanently deleted. + Existing annotations that reference removed attribute groups or attributes will lose their associated values. - .. warning:: - This operation replaces the entire attribute group structure of the annotation class. - Any attribute groups or attribute values omitted from the payload will be permanently removed. - Existing annotations that reference removed attribute groups or attributes will lose their associated values. + This action cannot be undone. :param project: The name or ID of the project. :type project: Union[str, int] @@ -2090,18 +2093,69 @@ def update_annotation_class( Request Example: :: - # Retrieve existing annotation class - annotation_class = sa_client.get_annotation_classes(project="classes", annotation_class="test_class") - - # Rename attribute value and add a new one - annotation_class["attribute_groups"][0]["attributes"][0]["name"] = "Brand Alpha" - annotation_class["attribute_groups"][0]["attributes"].append({"name": "Brand Beta"}) - - sa.update_annotation_classes( - project="test_set_folder_status", - name="test_class", - attribute_groups=annotation_class["attribute_groups"] - ) + annotation_class = sa_client.get_annotation_class(project='classes', annotation_class="Example Class") + attribute_groups = annotation_class["attribute_groups"] + + # Add a NEW Attribute to Existing Group + for group in attribute_groups: + if group["id"] == 5624734: + group["attributes"].append({ + "name": "blue" + }) + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Rename the Existing Attribute + for group in attribute_groups: + if group["id"] == 5624734: + for attr in group["attributes"]: + if attr["id"] == 11394966: + attr["name"] = "yellow" + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Rename the Attribute Group + for group in attribute_groups: + if group["id"] == 5624734: + group["name"] = "color" + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Add a Completely New Attribute Group + attribute_groups.append({ + "group_type": "text", + "name": "comment", + "isRequired": False, + "attributes": [] + }) + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Delete the Attribute Group + attribute_groups = [group for group in attribute_groups if group["id"] != 5659666] + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Delete the Attribute + for group in attribute_groups: + if group["id"] == 5624734: + group["attributes"] = [attr for attr in group["attributes"]if attr["id"] != 11394966] + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Set Default Value + for group in attribute_groups: + if group["id"] == 5624734: # color group + for attr in group["attributes"]: + if attr["id"] == 11438900: + attr["default"] = 1 + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + + # Make Group Required + for group in attribute_groups: + if group["id"] == 5624734: + group["isRequired"] = True + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) + # Change Group Type (Multiple Selection (Checklist) → Single Selection (Radio)) + for group in attribute_groups: + if group["id"] == 5624734: + group["group_type"] = "radio" + group["default_value"] = None # radio requires single default or None + sa_client.update_annotation_class(project='classes', name="Example Class", attribute_groups=attribute_groups) """ project = self.controller.get_project(project) From 1f69eae6867ce3c0b9ede1827c983716b4245d04 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 14:37:10 +0400 Subject: [PATCH 06/15] Update sdk interface optinal arguments annotations --- src/superannotate/lib/app/interface/sdk_interface.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index ef75bd43..be786582 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2794,7 +2794,7 @@ def upload_videos_from_folder_to_project( target_fps: Optional[int] = None, start_time: Optional[float] = 0.0, end_time: Optional[float] = None, - annotation_status: str = None, + annotation_status: Optional[str] = None, image_quality_in_editor: Optional[IMAGE_QUALITY] = None, ): """Uploads image frames from all videos with given extensions from folder_path to the project. @@ -3361,7 +3361,7 @@ def upload_annotations( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], annotations: List[dict], - keep_status: bool = None, + keep_status: Optional[bool] = None, *, data_spec: Literal["default", "multimodal"] = "default", ): @@ -4221,7 +4221,7 @@ def search_items( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], name_contains: NotEmptyStr = None, - annotation_status: str = None, + annotation_status: Optional[str] = None, annotator_email: Optional[NotEmptyStr] = None, qa_email: Optional[NotEmptyStr] = None, recursive: bool = False, @@ -4660,7 +4660,7 @@ def attach_items( attachments: Union[ NotEmptyStr, Path, Annotated[List[Attachment], Field(min_length=1)] ], - annotation_status: str = None, + annotation_status: Optional[str] = None, ): """ Link items from external storage to SuperAnnotate using URLs. @@ -5028,10 +5028,10 @@ def set_annotation_statuses( def download_annotations( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], - path: Union[str, Path] = None, + path: Optional[Union[str, Path]] = None, items: Optional[List[NotEmptyStr]] = None, recursive: bool = False, - callback: Callable = None, + callback: Optional[Callable] = None, data_spec: Literal["default", "multimodal"] = "default", ): """Downloads annotation JSON files of the selected items to the local directory. From c491acc7c442ad4b805fc51b40754de25cc6dfb2 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 14:41:00 +0400 Subject: [PATCH 07/15] Update readthedocs settings --- .readthedocs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index ca2222ce..f42d386d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,9 +6,9 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.8" + python: "3.12" sphinx: configuration: docs/source/conf.py From 6d0081cb7f454609b903ea8dce21e5862e1e3c39 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 14:54:30 +0400 Subject: [PATCH 08/15] Update readthedocs settings --- 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 be786582..39621252 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2058,7 +2058,7 @@ def update_annotation_class( Updates an existing annotation class by submitting a full, updated attribute_groups payload. You can add new attribute groups, add new attribute values, rename attribute groups, rename attribute values, delete attribute groups, delete attribute values, update attribute group types, update default attributes, and update the required state. This function does not support Multimodal projects. - Warning: + .. warning:: Use update_annotation_class() With Extreme Caution The update_annotation_class() method replaces the entire attribute group structure of the annotation class. Any attribute group or attribute group ID not included in the payload will be permanently deleted. From 2834b529add64792bc60b149909aa4994e4a680c Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 15:01:06 +0400 Subject: [PATCH 09/15] Update readthedocs settings --- src/superannotate/lib/app/interface/sdk_interface.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 39621252..8939a784 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -2059,13 +2059,13 @@ def update_annotation_class( This function does not support Multimodal projects. .. warning:: - Use update_annotation_class() With Extreme Caution - The update_annotation_class() method replaces the entire attribute group structure of the annotation class. - Any attribute group or attribute group ID not included in the payload will be permanently deleted. - Any attribute value or attribute ID not included in the payload will be permanently deleted. - Existing annotations that reference removed attribute groups or attributes will lose their associated values. + Use update_annotation_class() With Extreme Caution + The update_annotation_class() method replaces the entire attribute group structure of the annotation class. + Any attribute group or attribute group ID not included in the payload will be permanently deleted. + Any attribute value or attribute ID not included in the payload will be permanently deleted. + Existing annotations that reference removed attribute groups or attributes will lose their associated values. - This action cannot be undone. + This action cannot be undone. :param project: The name or ID of the project. :type project: Union[str, int] From 17e0ba5ff70a4c23230aeb373ae0ef07f8314106 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 10 Mar 2026 16:11:00 +0400 Subject: [PATCH 10/15] Update docstrings --- pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 34 ++++++++++++++----- 2 files changed, 27 insertions(+), 9 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 8939a784..12c932f0 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1999,6 +1999,7 @@ def get_annotation_class( project="classes", annotation_class="Example_class" ) + Response Example: :: @@ -2074,19 +2075,36 @@ def update_annotation_class( :type name: str :param attribute_groups: The full list of attribute groups for the class. + Each attribute group may contain: - - id (optional, required for existing groups) - - group_type (required): "radio", "checklist", "text", "numeric", or "ocr" - - name (required) - - isRequired (optional) - - default_value (optional) - - attributes (required, list) + :: + + * id (optional, required for existing groups) + * group_type (required) + * name (required) + * isRequired (optional) + * default_value (optional) + * attributes (list, required for Single and Multiple selection) Each attribute may contain: + :: + + * id (optional, required for existing attributes) + * name (required) + + The values for the group_type key are: + :: + + * radio (Single selection) + * checklist (Multiple selection) + * text (Text input) + * numeric (Numeric input) + * ocr (OCR input) + + The ocr key is only available for Vector projects. + - - id (optional, required for existing attributes) - - name (required) :type attribute_groups: list of dicts From ca1e5f610ff0110ae07ec5161a59a05c8bd97aa3 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Wed, 11 Mar 2026 16:53:57 +0400 Subject: [PATCH 11/15] fix releted pydantic --- src/superannotate/lib/core/entities/base.py | 20 ++++++--- src/superannotate/lib/core/enums.py | 45 +++++++++++++++++++ .../test_create_update_annotation_class.py | 8 ++++ tests/unit/test_init.py | 12 ++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 8a989e33..f4a5e6db 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -31,15 +31,21 @@ def _validate_hex_color(v: str) -> str: HexColor = Annotated[str, AfterValidator(_validate_hex_color)] -def _validate_string_date(v: datetime) -> str: - """Convert datetime to string format.""" +def _validate_string_date(v: Union[datetime, str]) -> str: + """Convert datetime to string format. For case data output.""" if isinstance(v, str): - return v - return v.isoformat().split("+")[0] + ".000Z" + try: + dt = datetime.fromisoformat(v.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00") + except (ValueError, AttributeError): + return v + elif isinstance(v, datetime): + return v.strftime("%Y-%m-%dT%H:%M:%S+00:00") + return v -def _serialize_string_date(v) -> str: - """Serialize datetime or string to string format.""" +def _serialize_string_date(v: Union[datetime, str]) -> str: + """Serialize datetime or string to string format. For case data input.""" if isinstance(v, str): return v if isinstance(v, datetime): @@ -127,7 +133,7 @@ def _validate_token(value: str) -> str: class ConfigEntity(BaseModel): model_config = ConfigDict(extra="ignore") - API_TOKEN: str = Field(alias="SA_TOKEN") + API_TOKEN: TokenStr = Field(alias="SA_TOKEN") API_URL: str = Field(alias="SA_URL", default=BACKEND_URL) LOGGING_LEVEL: Literal[ "NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" diff --git a/src/superannotate/lib/core/enums.py b/src/superannotate/lib/core/enums.py index ebabcc15..a8d2ec79 100644 --- a/src/superannotate/lib/core/enums.py +++ b/src/superannotate/lib/core/enums.py @@ -2,6 +2,10 @@ from enum import Enum from types import DynamicClassAttribute +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema +from pydantic_core import PydanticCustomError + class classproperty: # noqa def __init__(self, getter): @@ -33,6 +37,47 @@ def value(self) -> int: def __unicode__(self): return self.__doc__ + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler: GetCoreSchemaHandler): + """Customize Pydantic v2 validation to show titles in error messages.""" + return core_schema.no_info_after_validator_function( + cls._validate, + core_schema.union_schema( + [ + core_schema.is_instance_schema(cls), + core_schema.int_schema(), + core_schema.str_schema(), + ] + ), + serialization=core_schema.plain_serializer_function_ser_schema( + lambda x: x.value if isinstance(x, cls) else x, when_used="json" + ), + ) + + @classmethod + def _validate(cls, value): + """Validate and convert value to enum member.""" + if isinstance(value, cls): + return value + + # Try to find by value + if isinstance(value, int): + for enum in cls: + if enum.value == value: + return enum + + # Try to find by title or name + if isinstance(value, str): + for enum in cls: + if enum.__doc__ and enum.__doc__.lower() == value.lower(): + return enum + if value in cls.__members__: + return cls.__members__[value] + + # Build error message with titles + available = ", ".join(f"{enum.__doc__.lower()}" for enum in cls if enum.__doc__) + raise PydanticCustomError("enum", f"Input should be: {available}") + @classmethod def choices(cls) -> typing.Tuple[str]: """Return all titles as choices.""" diff --git a/tests/integration/classes/test_create_update_annotation_class.py b/tests/integration/classes/test_create_update_annotation_class.py index 8b4ba54d..8e2e839e 100644 --- a/tests/integration/classes/test_create_update_annotation_class.py +++ b/tests/integration/classes/test_create_update_annotation_class.py @@ -658,6 +658,14 @@ def test_update_annotation_class_without_group_type(self): ) assert res["attribute_groups"][0]["group_type"] == "radio" + def test_create_with_invalid_type(self): + try: + sa.create_annotation_class( + self.PROJECT_NAME, "tt", "#FFFFFF", class_type="invalid" + ) + except AppException as e: + assert "Input should be: object, tag, relationship" in str(e) + class TestVideoCreateAnnotationClasses(BaseTestCase): PROJECT_NAME = "TestVideoCreateAnnotationClasses" diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index ec0bf246..7b1a8aec 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -16,7 +16,7 @@ class ClientInitTestCase(TestCase): def test_init_via_invalid_token(self): _token = "123" - with self.assertRaisesRegex(AppException, r"(\s+)token(\s+)Invalid token."): + with self.assertRaisesRegex(AppException, r"Invalid token\."): SAClient(token=_token) @patch("lib.infrastructure.controller.Controller.get_current_user") @@ -61,9 +61,7 @@ def test_init_via_config_json_invalid_json(self): with open(f"{config_dir}/config.json", "w") as config_json: json.dump({"token": "INVALID_TOKEN"}, config_json) for kwargs in ({}, {"config_path": f"{config_dir}/config.json"}): - with self.assertRaisesRegex( - AppException, r"(\s+)token(\s+)Invalid token." - ): + with self.assertRaisesRegex(AppException, r"Invalid token\."): SAClient(**kwargs) @patch("lib.infrastructure.controller.Controller.get_current_user") @@ -137,7 +135,7 @@ def test_init_env(self, get_team, get_current_user): @patch.dict(os.environ, {"SA_URL": "SOME_URL", "SA_TOKEN": "SOME_TOKEN"}) def test_init_env_invalid_token(self): - with self.assertRaisesRegex(AppException, r"(\s+)SA_TOKEN(\s+)Invalid token."): + with self.assertRaisesRegex(AppException, r"Invalid token\."): SAClient() def test_init_via_config_ini_invalid_token(self): @@ -157,9 +155,7 @@ def test_init_via_config_ini_invalid_token(self): config_parser.write(config_ini) for kwargs in ({}, {"config_path": f"{config_dir}/config.ini"}): - with self.assertRaisesRegex( - AppException, r"(\s+)SA_TOKEN(\s+)Invalid token." - ): + with self.assertRaisesRegex(AppException, r"Invalid token\."): SAClient(**kwargs) def test_invalid_config_path(self): From 7a6e5ded428b6107bb4e69b5aaa8f7e4362f01fb Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 12 Mar 2026 12:26:22 +0400 Subject: [PATCH 12/15] pydantic related fix --- pytest.ini | 2 +- .../lib/app/interface/sdk_interface.py | 42 ++++++++++--------- .../projects/test_basic_project.py | 16 ------- .../test_project_custom_fields.py | 1 + 4 files changed, 24 insertions(+), 37 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 12c932f0..7e8f2802 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -150,9 +150,9 @@ def __init__( self.item = item self._annotation_adapter: Optional[BaseMultimodalAnnotationAdapter] = None self._overwrite = overwrite - self._annotation = None + self._annotation: Optional[dict] = None - def _set_small_annotation_adapter(self, annotation: dict = None): + def _set_small_annotation_adapter(self, annotation: Optional[dict] = None): self._annotation_adapter = MultimodalSmallAnnotationAdapter( project=self.project, folder=self.folder, @@ -162,7 +162,7 @@ def _set_small_annotation_adapter(self, annotation: dict = None): annotation=annotation, ) - def _set_large_annotation_adapter(self, annotation: dict = None): + def _set_large_annotation_adapter(self, annotation: Optional[dict] = None): self._annotation_adapter = MultimodalLargeAnnotationAdapter( project=self.project, folder=self.folder, @@ -341,7 +341,7 @@ def get_item_by_id( self, project_id: int, item_id: int, - include: List[Literal["custom_metadata", "categories"]] = None, + include: Optional[List[Literal["custom_metadata", "categories"]]] = None, ): """Returns the item metadata @@ -396,7 +396,7 @@ def get_item_by_id( return BaseSerializer(item).serialize(exclude={"url", "meta"}, by_alias=False) - def get_team_metadata(self, include: List[Literal["scores"]] = None): + def get_team_metadata(self, include: Optional[List[Literal["scores"]]] = None): """ Returns team metadata, including optionally, scores. @@ -421,7 +421,9 @@ def get_team_metadata(self, include: List[Literal["scores"]] = None): return TeamSerializer(team).serialize(exclude_unset=True) def get_user_metadata( - self, pk: Union[int, str], include: List[Literal["custom_fields"]] = None + self, + pk: Union[int, str], + include: Optional[List[Literal["custom_fields"]]] = None, ): """ Returns user metadata including optionally, custom fields @@ -514,8 +516,8 @@ def set_user_custom_field( def list_users( self, *, - project: Union[NotEmptyStr, int] = None, - include: List[Literal["custom_fields", "categories"]] = None, + project: Optional[Union[NotEmptyStr, int]] = None, + include: Optional[List[Literal["custom_fields", "categories"]]] = None, **filters, ): """ @@ -1074,9 +1076,9 @@ def retrieve_context( def search_team_contributors( self, - email: EmailStr = None, - first_name: NotEmptyStr = None, - last_name: NotEmptyStr = None, + email: Optional[EmailStr] = None, + first_name: Optional[NotEmptyStr] = None, + last_name: Optional[NotEmptyStr] = None, return_metadata: bool = True, ): """Search for contributors in the team @@ -1184,9 +1186,9 @@ def create_project( project_name: NotEmptyStr, project_description: NotEmptyStr, project_type: PROJECT_TYPE, - settings: List[Setting] = None, - classes: List[AnnotationClassEntity] = None, - workflows: Any = None, + settings: Optional[List[Setting]] = None, + classes: Optional[List[AnnotationClassEntity]] = None, + workflows: Optional[Any] = None, instructions_link: Optional[str] = None, workflow: Optional[str] = None, form: Optional[dict] = None, @@ -3250,7 +3252,7 @@ def set_project_steps( self, project: Union[NotEmptyStr, dict], steps: List[dict], - connections: List[List[int]] = None, + connections: Optional[List[List[int]]] = None, ): """Sets project's steps. @@ -3545,7 +3547,7 @@ def upload_image_annotations( annotation_json: Union[str, Path, dict], mask: Optional[Union[str, Path, bytes]] = None, verbose: Optional[bool] = True, - keep_status: bool = None, + keep_status: Optional[bool] = None, ): """Upload annotations from JSON to the image. @@ -3654,7 +3656,7 @@ def upload_image_to_project( img, image_name: Optional[NotEmptyStr] = None, annotation_status: Optional[str] = None, - from_s3_bucket=None, + from_s3_bucket: Optional[str] = None, image_quality_in_editor: Optional[NotEmptyStr] = None, ): """Uploads image (io.BytesIO() or filepath to image) to project. @@ -4238,7 +4240,7 @@ def get_item_metadata( def search_items( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], - name_contains: NotEmptyStr = None, + name_contains: Optional[NotEmptyStr] = None, annotation_status: Optional[str] = None, annotator_email: Optional[NotEmptyStr] = None, qa_email: Optional[NotEmptyStr] = None, @@ -4362,7 +4364,7 @@ def list_items( project: Union[NotEmptyStr, int], folder: Optional[Union[NotEmptyStr, int]] = None, *, - include: List[Literal["custom_metadata", "categories"]] = None, + include: Optional[List[Literal["custom_metadata", "categories"]]] = None, **filters, ): """ @@ -4549,7 +4551,7 @@ def list_items( def list_projects( self, *, - include: List[Literal["custom_fields"]] = None, + include: Optional[List[Literal["custom_fields"]]] = None, **filters, ): """ diff --git a/tests/integration/projects/test_basic_project.py b/tests/integration/projects/test_basic_project.py index 4bb519a0..6d8b777f 100644 --- a/tests/integration/projects/test_basic_project.py +++ b/tests/integration/projects/test_basic_project.py @@ -52,22 +52,6 @@ def annotation_path(self): def test_search(self): projects = sa.search_projects(self.PROJECT_NAME, return_metadata=True) assert projects - - sa.create_annotation_class( - self.PROJECT_NAME, - "class1", - "#FFAAFF", - [ - { - "name": "Human", - "attributes": [{"name": "yes"}, {"name": "no"}], - }, - { - "name": "age", - "attributes": [{"name": "young"}, {"name": "old"}], - }, - ], - ) sa.attach_items(self.PROJECT_NAME, attachments=[{"url": "", "name": "name"}]) annotation = json.load(open(self.annotation_path)) annotation["metadata"]["name"] = "name" diff --git a/tests/integration/work_management/test_project_custom_fields.py b/tests/integration/work_management/test_project_custom_fields.py index d1125733..e30e7769 100644 --- a/tests/integration/work_management/test_project_custom_fields.py +++ b/tests/integration/work_management/test_project_custom_fields.py @@ -320,6 +320,7 @@ def test_list_projects_by_custom_fields(self): ) == 1 ) + sa.delete_project(other_project_name) def test_list_projects_by_custom_invalid_field(self): with self.assertRaisesRegex(AppException, "Invalid filter param provided."): From 472a06538de9b36961f52f92d95be9c292444692 Mon Sep 17 00:00:00 2001 From: Narek Mkhitaryan Date: Thu, 19 Mar 2026 12:12:17 +0400 Subject: [PATCH 13/15] fix base entity dates --- .../lib/app/interface/sdk_interface.py | 20 +++++++++---------- src/superannotate/lib/core/entities/base.py | 12 +++-------- .../projects/test_create_project.py | 2 +- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 7e8f2802..e8cf6a45 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -1805,8 +1805,8 @@ def get_project_metadata( :: { - "createdAt": "2025-02-04T12:04:01+00:00", - "updatedAt": "2024-02-04T12:04:01+00:00", + "createdAt": "2025-02-04T12:04:01.000Z", + "updatedAt": "2024-02-04T12:04:01.000Z", "id": 902174, "team_id": 233435, "name": "Medical Annotations", @@ -1820,8 +1820,8 @@ def get_project_metadata( "folder_id": 1191383, "workflow_id": 1, "workflow": { - "createdAt": "2024-09-03T12:48:09+00:00", - "updatedAt": "2024-09-03T12:48:09+00:00", + "createdAt": "2024-09-03T12:48:09.000Z", + "updatedAt": "2024-09-03T12:48:09.000Z", "id": 1, "name": "System workflow", "type": "system", @@ -4640,7 +4640,7 @@ def list_projects( "classes": [], "completed_items_count": None, "contributors": [], - "createdAt": "2025-02-04T12:04:01+00:00", + "createdAt": "2025-02-04T12:04:01.000Z", "creator_id": "ecample@email.com", "custom_fields": { "Notes": "Something", @@ -4662,7 +4662,7 @@ def list_projects( "status": "InProgress", "team_id": 233435, "type": "Vector", - "updatedAt": "2024-02-04T12:04:01+00:00", + "updatedAt": "2024-02-04T12:04:01.000Z", "upload_state": "INITIAL", "users": [], "workflow_id": 1, @@ -5687,8 +5687,8 @@ def list_workflows(self): [ { - "createdAt": "2024-09-03T12:48:09+00:00", - "updatedAt": "2024-09-04T12:48:09+00:00", + "createdAt": "2024-09-03T12:48:09.000Z", + "updatedAt": "2024-09-04T12:48:09.000Z", "id": 1, "name": "System workflow", "type": "system", @@ -5696,8 +5696,8 @@ def list_workflows(self): "raw_config": {"roles": ["Annotator", "QA"], ...} }, { - "createdAt": "2025-01-03T12:48:09+00:00", - "updatedAt": "2025-01-05T12:48:09+00:00", + "createdAt": "2025-01-03T12:48:09.000Z", + "updatedAt": "2025-01-05T12:48:09.000Z", "id": 58758, "name": "Custom workflow", "type": "user", diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index f4a5e6db..f3e1ae8d 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -32,16 +32,10 @@ def _validate_hex_color(v: str) -> str: def _validate_string_date(v: Union[datetime, str]) -> str: - """Convert datetime to string format. For case data output.""" + """Convert datetime to string format.""" if isinstance(v, str): - try: - dt = datetime.fromisoformat(v.replace("Z", "+00:00")) - return dt.strftime("%Y-%m-%dT%H:%M:%S+00:00") - except (ValueError, AttributeError): - return v - elif isinstance(v, datetime): - return v.strftime("%Y-%m-%dT%H:%M:%S+00:00") - return v + return v + return v.isoformat().split("+")[0] + ".000Z" def _serialize_string_date(v: Union[datetime, str]) -> str: diff --git a/tests/integration/projects/test_create_project.py b/tests/integration/projects/test_create_project.py index d301a892..40af6517 100644 --- a/tests/integration/projects/test_create_project.py +++ b/tests/integration/projects/test_create_project.py @@ -85,7 +85,7 @@ class TestCreateVectorProject(ProjectCreateBaseTestCase): def test_create_project_datetime(self): project = sa.create_project(self.PROJECT, "desc", self.PROJECT_TYPE) metadata = sa.get_project_metadata(project["name"]) - assert "Z" not in metadata["createdAt"] + assert "Z" in metadata["createdAt"] def test_create_project_with_wrong_type(self): with self.assertRaisesRegex( From fe5857fec5e264c5a5858db382b2d93a1050ec60 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:36:23 +0400 Subject: [PATCH 14/15] 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 e36320e3..a362f6d2 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import os import sys -__version__ = "4.5.3dev1" +__version__ = "4.5.3" os.environ.update({"sa_version": __version__}) From 0c15c95e39eec5c83dd7c41892ae21b2c9a03933 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:39:11 +0400 Subject: [PATCH 15/15] Update CHANGELOG.rst --- CHANGELOG.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0014c0ac..4f27dc2c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,16 @@ History All release highlights of this project will be documented in this file. +4.5.3 - March 26, 2026 +________________________ + +**Updated** + - SDK will now support Python version 3.14. + +**Fixed** + - ``SAClient.get_item_by_id()`` to return item assignment metadata when the project has a folder. + + 4.5.2 - March 1, 2026 ________________________