Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ History

All release highlights of this project will be documented in this file.


4.5.1 - February 5, 2026
________________________

**Added**

- ``SAClient.get_folder_metadata`` Now returns a list of metadata of contributors assigned to the folder.

**Updated**

- SDK will now support Python versions 3.10+.


4.5.0 - December 4, 2025
________________________

Expand Down
4 changes: 1 addition & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,10 @@ SuperAnnotate python SDK is available on PyPI:
pip install superannotate


The package officially supports Python 3.7+ and was tested under Linux and
The package officially supports Python 3.10+ and was tested under Linux and
Windows (`Anaconda <https://www.anaconda.com/products/individual#windows>`__
) platforms.

For more detailed installation steps and package usage please have a look at the `tutorial <https://superannotate.readthedocs.io/en/stable/tutorial.sdk.html>`__


Supported Features
------------------
Expand Down
2 changes: 1 addition & 1 deletion docs/source/userguide/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ SDK is available on PyPI:

pip install superannotate

The package officially supports Python 3.7+ and was tested under Linux and
The package officially supports Python 3.10+ and was tested under Linux and
Windows (`Anaconda <https://www.anaconda.com/products/individual#windows>`_) platforms.

For certain video related functions to work, ffmpeg package needs to be installed.
Expand Down
8 changes: 4 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,15 @@ def get_version():
classifiers=[
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
],
project_urls={
"Documentation": "https://superannotate.readthedocs.io/en/stable/",
},
python_requires=">=3.7",
python_requires=">=3.10",
include_package_data=True,
)
2 changes: 1 addition & 1 deletion src/superannotate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import sys


__version__ = "4.5.0"
__version__ = "4.5.1"


os.environ.update({"sa_version": __version__})
Expand Down
10 changes: 8 additions & 2 deletions src/superannotate/lib/app/interface/base_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ def get_default_payload(team_name, user_email):
def __init__(self, function):
self.function = function
self._client = None
self.skip_flag = os.environ.get("SA_SKIP_METRICS", "False").lower() in (
"true",
"1",
"t",
)
functools.update_wrapper(self, function)

def get_client(self):
Expand Down Expand Up @@ -190,8 +195,9 @@ def default_parser(function_name: str, kwargs: dict) -> tuple:
return function_name, properties

def _track(self, user_id: str, event_name: str, data: dict):
if "pytest" not in sys.modules:
self.get_mp_instance().track(user_id, event_name, data)
if "pytest" in sys.modules or self.skip_flag:
return
self.get_mp_instance().track(user_id, event_name, data)

def _track_method(self, args, kwargs, success: bool):
try:
Expand Down
111 changes: 96 additions & 15 deletions src/superannotate/lib/app/interface/sdk_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@
from lib.app.serializers import WMProjectSerializer
from lib.core.entities.work_managament import WMUserTypeEnum
from lib.core.jsx_conditions import EmptyQuery
from lib.core.jsx_conditions import Join
from lib.core.jsx_conditions import Fields
from lib.core.entities.items import ProjectCategoryEntity

logger = logging.getLogger("sa")
Expand Down Expand Up @@ -511,7 +513,7 @@ def set_user_custom_field(
def list_users(
self,
*,
project: Union[int, str] = None,
project: Union[NotEmptyStr, int] = None,
include: List[Literal["custom_fields", "categories"]] = None,
**filters,
):
Expand Down Expand Up @@ -1088,7 +1090,12 @@ def search_team_contributors(
:return: metadata of found users
:rtype: list of dicts
"""

warnings.warn(
"This function search_team_contributors() will be deprecated and removed in version 4.6.0\n"
"Recommended replacement: get_user_metadata() or list_users()",
DeprecationWarning,
stacklevel=2,
)
contributors = self.controller.search_team_contributors(
email=email, first_name=first_name, last_name=last_name
).data
Expand Down Expand Up @@ -1124,6 +1131,13 @@ def search_projects(
:return: project names or metadatas
:rtype: list of strs or dicts
"""
warnings.warn(
"This function search_projects() will be deprecated and removed in version 4.6.0\n"
"Recommended replacement: get_project_metadata() or list_projects()",
DeprecationWarning,
stacklevel=2,
)

statuses = []
if status:
if isinstance(status, (list, tuple, set)):
Expand Down Expand Up @@ -1574,22 +1588,83 @@ def rename_project(self, project: NotEmptyStr, new_name: NotEmptyStr):
)
return ProjectSerializer(response.data).serialize()

def get_folder_metadata(self, project: NotEmptyStr, folder_name: NotEmptyStr):
"""Returns folder metadata
def get_folder_metadata(
self,
project: NotEmptyStr,
folder_name: NotEmptyStr,
include_contributors: bool = False,
):
"""
SAClient.get_folder_metadata(project, folder_name, include_contributors=False)
Returns folder metadata. Optionally includes a list of contributors that are currently
assigned to the folder.

:param project: project name
:type project: str

:param folder_name: folder's name
:type folder_name: str

:return: metadata of folder
:param include_contributors: If True, includes a list of contributors assigned to the folder in the response.
Defaults to False.
:type include_contributors: bool

:return: Folder metadata
:rtype: dict

Request Example:
::

sa_client.get_folder_metadata(
project="test_project",
folder_name="test_folder",
include_contributors=True
)


Response Example:
::

{
"createdAt": "2025-10-27T06:54:09.000Z",
"updatedAt": "2025-10-27T06:54:09.000Z",
"id": 1487195,
"name": "test_folder",
"status": "NotStarted",
"project_id": 1203397,
"team_id": 85922,
"contributors": [
{
"email": "test@superannotate.com",
"id": 1314658,
"role": "Annotator",
"state": "Confirmed"
}
]
}

"""
project, folder = self.controller.get_project_folder((project, folder_name))
if not folder:
project = self.controller.get_project(project)
query = Filter("name", folder_name, OperatorEnum.EQ)
fields = ["id", "project_id", "name", "status", "team_id"]

if include_contributors:
query &= Join("folderUsers", fields=["id"])
query &= Join(
"folderUsers.projectUser", fields=["id", "email", "role", "state"]
)
fields.append("folderUsers")
query &= Fields(fields)
response = self.controller.work_management.list_folders(
project=project, query=query
)
response.raise_for_status()
if not response.data:
raise AppException("Folder not found.")
return BaseSerializer(folder).serialize(exclude={"completedCount", "is_root"})
folder = response.data[0]
return BaseSerializer(folder).serialize(
exclude={"completedCount", "is_root"}, by_alias=False
)

def delete_folders(self, project: NotEmptyStr, folder_names: List[NotEmptyStr]):
"""Delete folder in project.
Expand Down Expand Up @@ -2148,7 +2223,7 @@ def assign_folder(
raise AppException(response.errors)
project = response.data
project_contributors = self.controller.work_management.list_users(
project=project
email__in=users, project=project
)
verified_users = [i.email for i in project_contributors]
verified_users = set(users).intersection(set(verified_users))
Expand Down Expand Up @@ -3949,6 +4024,12 @@ def search_items(
}
]
"""
warnings.warn(
"This function search_items() will be deprecated and removed in version 4.6.0\n"
"Recommended replacement: get_item_metadata() or list_items()",
DeprecationWarning,
stacklevel=2,
)
project, folder = self.controller.get_project_folder(project)
query_kwargs = {"include": ["assignments"]}
if name_contains:
Expand Down Expand Up @@ -4355,7 +4436,7 @@ def attach_items(
if annotation_status is not None:
warnings.warn(
DeprecationWarning(
"The “keep_status” parameter is deprecated. "
"The “keep_status” parameter is deprecated."
"Please use the “set_annotation_statuses” function instead."
)
)
Expand Down Expand Up @@ -4577,7 +4658,7 @@ def move_items(
def set_items_category(
self,
project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]],
items: List[Union[int, str]],
items: List[Union[NotEmptyStr, int]],
category: NotEmptyStr,
):
"""
Expand Down Expand Up @@ -4615,7 +4696,7 @@ def set_items_category(
def remove_items_category(
self,
project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]],
items: List[Union[int, str]],
items: List[Union[NotEmptyStr, int]],
):
"""
Remove categories from one or more items.
Expand Down Expand Up @@ -5349,7 +5430,7 @@ def remove_users(self, users: Union[List[int], List[str]]):
Request Example:
::

SAClient.remove_users(member=["example@gmail.com","example1@gmail.com"])
sa_client.remove_users(users=["example@gmail.com","example1@gmail.com"])

"""
success = 0
Expand All @@ -5369,7 +5450,7 @@ def remove_users_from_project(
self, project: Union[NotEmptyStr, int], users: Union[List[int], List[str]]
):
"""
Allows removing users from the team.
Allows removing users from a project.

:param project: The name or ID of the project.
:type project: Union[NotEmptyStr, int]
Expand All @@ -5382,7 +5463,7 @@ def remove_users_from_project(
Request Example:
::

SAClient.remove_users_from_project(project="Test Project", users=["example@gmail.com","example1@gmail.com"])
sa_client.remove_users_from_project(project="Test Project", users=["example@gmail.com","example1@gmail.com"])

"""
project = self.controller.get_project(project)
Expand Down
3 changes: 1 addition & 2 deletions src/superannotate/lib/app/serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from abc import ABC
from enum import Enum
from typing import Any
from typing import List
Expand All @@ -10,7 +9,7 @@
from lib.core.pydantic_v1 import BaseModel


class BaseSerializer(ABC):
class BaseSerializer:
def __init__(self, entity: BaseEntity):
self._entity = entity

Expand Down
46 changes: 45 additions & 1 deletion src/superannotate/lib/core/entities/folder.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
from enum import Enum
from typing import List
from typing import Optional

from lib.core.entities.base import BaseModel
from lib.core.entities.base import TimedBaseModel
from lib.core.enums import FolderStatus
from lib.core.enums import WMUserStateEnum
from lib.core.pydantic_v1 import Extra
from lib.core.pydantic_v1 import Field
from lib.core.pydantic_v1 import root_validator


class FolderUserEntity(BaseModel):
email: Optional[str] = None
id: Optional[int] = None
role: Optional[int] = None
state: Optional[WMUserStateEnum] = None

class Config:
use_enum_names = True
allow_population_by_field_name = True
extra = Extra.ignore
json_encoders = {Enum: lambda v: v.value}


class FolderEntity(TimedBaseModel):
Expand All @@ -13,8 +31,34 @@ class FolderEntity(TimedBaseModel):
project_id: Optional[int]
team_id: Optional[int]
is_root: Optional[bool] = False
folder_users: Optional[List[dict]]
contributors: Optional[List[FolderUserEntity]] = Field(
default_factory=list, alias="folderUsers"
)

completedCount: Optional[int]

@root_validator(pre=True)
def normalize_folder_users(cls, values: dict) -> dict:
folder_users = values.get("folderUsers")
if not folder_users:
return values

normalized: List[dict] = []
for fu in folder_users:
pu = fu.get("projectUser") or {}

normalized.append(
{
"email": pu.get("email"),
"id": pu.get("id"),
"role": pu.get("role"),
"state": pu.get("state"),
}
)

values["folderUsers"] = normalized
return values

class Config:
extra = Extra.ignore
allow_population_by_field_name = True
Loading
Loading