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 .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ repos:
- id: black
name: Code Formatter (black)
- repo: 'https://github.com/PyCQA/flake8'
rev: 3.8.2
rev: 6.1.0
hooks:
- id: flake8
exclude: src/lib/app/analytics | src/lib/app/converters | src/lib/app/input_converters
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ History

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

4.4.39 - November 13, 2025
________________________

**Updated**

- ``SAClient.get_item_by_id`` now supports an optional include parameter to fetch additional fields like custom_metadata and categories.

**Updated**

- ``SAClient.assign_items`` now supports assigning items and folders to pending users.
- ``SAClient.assign_folder`` now supports assigning items and folders to pending users.

4.4.38 - August 20, 2025
________________________

Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ pydantic>=1.10,!=2.0.*
aiohttp~=3.8
boto3~=1.26
opencv-python-headless~=4.7
packaging~=24.0
plotly~=5.14
pandas~=2.0
pillow>=9.5,~=10.0
Expand Down
6 changes: 3 additions & 3 deletions requirements_extra.txt
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
setuptools~=67.7
wheel~=0.40
Sphinx==6.2.1
tox==4.5.1
Sphinx~=6.2.1
tox~=4.0
sphinx_rtd_theme==1.2.0
furo==2023.3.27
jaraco.tidelift==1.5.1
sphinx-notfound-page==0.8.3
sphinx_inline_tabs==2023.4.21
pytest==7.3.1
pytest~=8.0
pytest-xdist==3.2.1
pytest-parallel==0.1.1
pytest-cov==4.0.0
Expand Down
10 changes: 5 additions & 5 deletions src/superannotate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import sys


__version__ = "4.4.38"
__version__ = "4.4.39"


os.environ.update({"sa_version": __version__})
sys.path.append(os.path.split(os.path.realpath(__file__))[0])

import requests
from lib.core import enums
from packaging.version import parse
from lib.core.utils import parse_version
from lib.core import PACKAGE_VERSION_UPGRADE
from lib.core import PACKAGE_VERSION_INFO_MESSAGE
from lib.core import PACKAGE_VERSION_MAJOR_UPGRADE
Expand Down Expand Up @@ -47,15 +47,15 @@

def log_version_info():
logging.StreamHandler(sys.stdout)
local_version = parse(__version__)
local_version = parse_version(__version__)
if local_version.is_prerelease:
logging.info(PACKAGE_VERSION_INFO_MESSAGE.format(__version__))
req = requests.get("https://pypi.org/pypi/superannotate/json")
if req.ok:
releases = req.json().get("releases", [])
pip_version = parse("0")
pip_version = parse_version("0")
for release in releases:
ver = parse(release)
ver = parse_version(release)
if not ver.is_prerelease or local_version.is_prerelease:
pip_version = max(pip_version, ver)
if pip_version.major > local_version.major:
Expand Down
2 changes: 1 addition & 1 deletion src/superannotate/lib/app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,5 @@ def wrap_error(errors_list: List[Tuple[str, str]]) -> str:
_tabulation = tabulation - len(key)
if not key:
key, value, _tabulation = value, "", 0
msgs.append(f'{key}{ " " * _tabulation}{value}')
msgs.append(f'{key}{" " * _tabulation}{value}')
return "\n".join(msgs)
64 changes: 49 additions & 15 deletions src/superannotate/lib/app/interface/sdk_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,12 @@ def get_folder_by_id(self, project_id: int, folder_id: int):
exclude={"completedCount", "is_root"}
)

def get_item_by_id(self, project_id: int, item_id: int):
def get_item_by_id(
self,
project_id: int,
item_id: int,
include: List[Literal["custom_metadata", "categories"]] = None,
):
"""Returns the item metadata

:param project_id: the id of the project
Expand All @@ -344,16 +349,50 @@ def get_item_by_id(self, project_id: int, item_id: int):
:param item_id: the id of the item
:type item_id: int

:param include: Specifies additional fields to include in the response.

Possible values are

- "custom_metadata": Includes custom metadata attached to the item.
- "categories": Includes categories attached to the item.
:type include: list of str, optional

:return: item metadata
:rtype: dict
"""
project_response = self.controller.get_project_by_id(project_id=project_id)
project_response.raise_for_status()
item = self.controller.get_item_by_id(
item_id=item_id, project=project_response.data

if (
include
and "categories" in include
and project_response.data.type != ProjectType.MULTIMODAL.value
):
raise AppException(
"The 'categories' option in the 'include' field is only supported for Multimodal projects."
)

# always join assignments for all project types
_include = {"assignments"}
if include:
_include.update(set(include))
include = list(_include)

include_custom_metadata = "custom_metadata" in include
if include_custom_metadata:
include.remove("custom_metadata")

item = self.controller.items.get_item_by_id(
item_id=item_id, project=project_response.data, include=include
)

return BaseSerializer(item).serialize(exclude={"url", "meta"})
if include_custom_metadata:
item_custom_fields = self.controller.custom_fields.list_fields(
project=project_response.data, item_ids=[item.id]
)
item.custom_metadata = item_custom_fields[item.id]

return BaseSerializer(item).serialize(exclude={"url", "meta"}, by_alias=False)

def get_team_metadata(self, include: List[Literal["scores"]] = None):
"""
Expand Down Expand Up @@ -2102,15 +2141,10 @@ def assign_folder(
if response.errors:
raise AppException(response.errors)
project = response.data
response = self.controller.projects.get_metadata(
project=project, include_contributors=True
project_contributors = self.controller.work_management.list_users(
project=project
)

if response.errors:
raise AppException(response.errors)

contributors = response.data.users
verified_users = [i.user_id for i in contributors]
verified_users = [i.email for i in project_contributors]
verified_users = set(users).intersection(set(verified_users))
unverified_contributor = set(users) - verified_users

Expand Down Expand Up @@ -4161,7 +4195,7 @@ def list_projects(

:param filters: Specifies filtering criteria, with all conditions combined using logical AND.

- Only users matching all filter conditions are returned.
- Only projects matching all filter conditions are returned.

- If no filter operation is provided, an exact match is applied.

Expand Down Expand Up @@ -4195,7 +4229,7 @@ def list_projects(
Custom Fields Filtering:

- Custom fields must be prefixed with `custom_field__`.
- Example: custom_field__Due_date__gte="1738281600" (filtering users whose Due_date is after the given Unix timestamp).
- Example: custom_field__Due_date__gte="1738281600" (filtering projects whose Due_date is after the given Unix timestamp).
- If include does not include “custom_fields” but filter contains ‘custom_field’, an error will be returned

- **Text** custom field only works with the following filter params: __in, __notin, __contains
Expand Down Expand Up @@ -5237,7 +5271,7 @@ def item_context(
"This function is only supported for Multimodal projects."
)
if isinstance(item, int):
_item = self.controller.get_item_by_id(item_id=item, project=project)
_item = self.controller.items.get_item_by_id(item_id=item, project=project)
else:
items = self.controller.items.list_items(project, folder, name=item)
if not items:
Expand Down
18 changes: 15 additions & 3 deletions src/superannotate/lib/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import os
import sys
from logging import Formatter
from logging.handlers import RotatingFileHandler
from os.path import expanduser
Expand Down Expand Up @@ -36,10 +37,21 @@ def setup_logging(level=DEFAULT_LOGGING_LEVEL, file_path=LOG_FILE_LOCATION):
logger.removeHandler(handler)
logger.propagate = False
logger.setLevel(level)
stream_handler = logging.StreamHandler()

# Separate handlers for different log levels
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(logging.DEBUG)
stdout_handler.addFilter(lambda record: record.levelno < logging.WARNING)

stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.WARNING)

formatter = Formatter("SA-PYTHON-SDK - %(levelname)s - %(message)s")
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
stdout_handler.setFormatter(formatter)
stderr_handler.setFormatter(formatter)

logger.addHandler(stdout_handler)
logger.addHandler(stderr_handler)
try:
os.makedirs(file_path, exist_ok=True)
log_file_path = os.path.join(file_path, "sa.log")
Expand Down
3 changes: 2 additions & 1 deletion src/superannotate/lib/core/pydantic_v1.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from packaging.version import parse as parse_version
from lib.core.utils import parse_version
from pydantic import VERSION


if parse_version(VERSION).major < 2:
import pydantic
else:
Expand Down
56 changes: 56 additions & 0 deletions src/superannotate/lib/core/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import re
import typing
from threading import Thread

Expand Down Expand Up @@ -46,3 +47,58 @@ def wrapper(func: typing.Callable):
thread.start()
thread.join()
return response[0]


def parse_version(version_string):
"""Smart version parsing with support for various formats"""
# Remove 'v' prefix if present
version_string = version_string.lstrip("v")

# Extract version parts using regex
match = re.match(
r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:[-.]?(a|b|rc|dev|alpha|beta)(\d*))?",
version_string,
)

if not match:
raise ValueError(f"Invalid version format: {version_string}")

major = int(match.group(1))
minor = int(match.group(2) or 0)
patch = int(match.group(3) or 0)
pre_type = match.group(4)
pre_num = int(match.group(5) or 0) if match.group(5) else 0

class Version:
def __init__(self, major, minor, patch, pre_type=None, pre_num=0):
self.major = major
self.minor = minor
self.patch = patch
self.pre_type = pre_type
self.pre_num = pre_num

@property
def is_prerelease(self):
return self.pre_type is not None

def __str__(self):
version = f"{self.major}.{self.minor}.{self.patch}"
if self.pre_type:
version += f"-{self.pre_type}{self.pre_num}"
return version

def __gt__(self, other):
if self.major != other.major:
return self.major > other.major
if self.minor != other.minor:
return self.minor > other.minor
if self.patch != other.patch:
return self.patch > other.patch
# Handle prerelease comparison
if self.is_prerelease and not other.is_prerelease:
return False
if not self.is_prerelease and other.is_prerelease:
return True
return self.pre_num > other.pre_num

return Version(major, minor, patch, pre_type, pre_num)
Loading
Loading