diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 463755987..bb0bdabac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: 'https://github.com/asottile/reorder_python_imports' - rev: v3.13.0 + rev: v3.16.0 hooks: - id: reorder-python-imports exclude: src/lib/app/analytics | src/lib/app/converters | src/lib/app/input_converters @@ -9,12 +9,12 @@ repos: - '--application-directories' - app - repo: 'https://github.com/python/black' - rev: 22.3.0 + rev: 26.1.0 hooks: - id: black name: Code Formatter (black) - repo: 'https://github.com/PyCQA/flake8' - rev: 6.1.0 + rev: 7.3.0 hooks: - id: flake8 exclude: src/lib/app/analytics | src/lib/app/converters | src/lib/app/input_converters @@ -23,7 +23,7 @@ repos: - '--max-line-length=120' - --ignore=D100,D203,D405,W503,E203,E501,F841,E126,E712,E123,E131,F821,E121,W605,E402 - repo: 'https://github.com/asottile/pyupgrade' - rev: v2.4.3 + rev: v3.21.2 hooks: - id: pyupgrade exclude: src/lib/app/analytics | src/lib/app/converters | src/lib/app/input_converters @@ -31,7 +31,7 @@ repos: args: - '--py37-plus' - repo: 'https://github.com/PyCQA/doc8' - rev: v1.1.1 + rev: v2.0.0 hooks: - id: doc8 name: doc8 diff --git a/.readthedocs.yml b/.readthedocs.yml index ca2222ce8..f42d386db 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 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0014c0ac1..4f27dc2ca 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 ________________________ diff --git a/log.txt b/log.txt new file mode 100644 index 000000000..e69de29bb diff --git a/pytest.ini b/pytest.ini index d9f7f6cc3..c0f66b58e 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 d8a23ef1b..9f0a0efd2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,15 @@ -pydantic>=1.10,<3,!=2.0.* +pydantic~=2.5 +pydantic-extra-types~=2.11 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 +typing_extensions~=4.15 superannotate-schemas==1.0.49 \ No newline at end of file diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 821d8f5e2..a362f6d25 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,8 +2,7 @@ import os import sys - -__version__ = "4.5.2" +__version__ = "4.5.3" os.environ.update({"sa_version": __version__}) @@ -22,7 +21,6 @@ from superannotate.lib.app.interface.sdk_interface import SAClient from superannotate.lib.app.interface.sdk_interface import ItemContext - SESSIONS = {} diff --git a/src/superannotate/lib/app/analytics/common.py b/src/superannotate/lib/app/analytics/common.py index bb39a5b74..85222d98b 100644 --- a/src/superannotate/lib/app/analytics/common.py +++ b/src/superannotate/lib/app/analytics/common.py @@ -6,7 +6,6 @@ import plotly.express as px from lib.core.exceptions import AppException - logger = logging.getLogger("sa") diff --git a/src/superannotate/lib/app/common.py b/src/superannotate/lib/app/common.py index abc13459f..573736b7a 100644 --- a/src/superannotate/lib/app/common.py +++ b/src/superannotate/lib/app/common.py @@ -24,11 +24,11 @@ def blue_color_generator(n, hex_values=True): ) hex_color = ( "#" - + "{:02x}".format(bgr_color[2]) + + f"{bgr_color[2]:02x}" + "{:02x}".format( bgr_color[1], ) - + "{:02x}".format(bgr_color[0]) + + f"{bgr_color[0]:02x}" ) if hex_values: hex_colors.append(hex_color) diff --git a/src/superannotate/lib/app/input_converters/__init__.py b/src/superannotate/lib/app/input_converters/__init__.py index 51895218c..3fcc93e28 100644 --- a/src/superannotate/lib/app/input_converters/__init__.py +++ b/src/superannotate/lib/app/input_converters/__init__.py @@ -1,7 +1,6 @@ from lib.app.input_converters.conversion import export_annotation from lib.app.input_converters.conversion import import_annotation - __all__ = [ "export_annotation", "import_annotation", diff --git a/src/superannotate/lib/app/input_converters/conversion.py b/src/superannotate/lib/app/input_converters/conversion.py index 1b1f83502..57f28855f 100644 --- a/src/superannotate/lib/app/input_converters/conversion.py +++ b/src/superannotate/lib/app/input_converters/conversion.py @@ -1,6 +1,7 @@ """ Main module for input converters """ + import os import shutil import tempfile @@ -15,7 +16,6 @@ from .export_from_sa_conversions import export_from_sa from .import_to_sa_conversions import import_to_sa - ALLOWED_TASK_TYPES = [ "panoptic_segmentation", "instance_segmentation", @@ -108,7 +108,7 @@ def _passes_value_sanity(values_info): for value in values_info: if value[0] not in value[2]: raise AppException( - "'{}' should be one of the following '{}'".format(value[1], value[2]) + f"'{value[1]}' should be one of the following '{value[2]}'" ) diff --git a/src/superannotate/lib/app/input_converters/converters/baseStrategy.py b/src/superannotate/lib/app/input_converters/converters/baseStrategy.py index 71f55f23f..65f5d9050 100644 --- a/src/superannotate/lib/app/input_converters/converters/baseStrategy.py +++ b/src/superannotate/lib/app/input_converters/converters/baseStrategy.py @@ -1,5 +1,5 @@ -""" -""" +""" """ + import logging from .coco_converters.coco_to_sa_vector import coco_instance_segmentation_to_sa_vector diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py index a7b07b763..35da00364 100644 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py +++ b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_converter.py @@ -1,5 +1,5 @@ -""" -""" +""" """ + import json import logging from collections import namedtuple diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_strategies.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_strategies.py index 9f05f96dc..0beb9c900 100644 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_strategies.py +++ b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_strategies.py @@ -1,5 +1,5 @@ -""" -""" +""" """ + import logging import threading from pathlib import Path diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py index 85a7206da..cd213c0b3 100644 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py +++ b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_pixel.py @@ -1,6 +1,7 @@ """ COCO to SA conversion method """ + import logging from .coco_api import _maskfrRLE diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_vector.py index f560e60b6..e3b67362d 100644 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/coco_converters/coco_to_sa_vector.py @@ -1,6 +1,7 @@ """ COCO to SA conversion methods """ + import json import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_vector_to_coco.py b/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_vector_to_coco.py index efb6b3e08..b546d57a2 100644 --- a/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_vector_to_coco.py +++ b/src/superannotate/lib/app/input_converters/converters/coco_converters/sa_vector_to_coco.py @@ -1,6 +1,7 @@ """ SA to COCO conversion methods """ + import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/converters.py b/src/superannotate/lib/app/input_converters/converters/converters.py index a07a697d3..3b6b25d46 100644 --- a/src/superannotate/lib/app/input_converters/converters/converters.py +++ b/src/superannotate/lib/app/input_converters/converters/converters.py @@ -4,6 +4,7 @@ said strategy. """ + from .coco_converters.coco_strategies import CocoKeypointDetectionStrategy from .coco_converters.coco_strategies import CocoObjectDetectionStrategy from .coco_converters.coco_strategies import CocoPanopticConverterStrategy diff --git a/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_strategies.py b/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_strategies.py index bc1b640e1..5ac9c5006 100644 --- a/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_strategies.py +++ b/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_strategies.py @@ -1,5 +1,5 @@ -""" -""" +""" """ + import numpy as np from ....common import write_to_json diff --git a/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_to_sa_vector.py index c0c10cdf8..1a2fbc5c0 100644 --- a/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/dataloop_converters/dataloop_to_sa_vector.py @@ -1,6 +1,7 @@ """ Dataloop to SA conversion method """ + import json import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/googlecloud_converters/googlecloud_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/googlecloud_converters/googlecloud_to_sa_vector.py index 644cb934a..6e2552616 100644 --- a/src/superannotate/lib/app/input_converters/converters/googlecloud_converters/googlecloud_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/googlecloud_converters/googlecloud_to_sa_vector.py @@ -1,6 +1,7 @@ """ Googlecloud to SA conversion method """ + import logging import threading from pathlib import Path diff --git a/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_helper.py b/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_helper.py index 928697eb2..181df9e9e 100644 --- a/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_helper.py +++ b/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_helper.py @@ -1,6 +1,5 @@ import logging - logger = logging.getLogger("sa") diff --git a/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_to_sa_vector.py index a380787a4..e71de1377 100644 --- a/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/labelbox_converters/labelbox_to_sa_vector.py @@ -1,6 +1,7 @@ """ Labelbox to SA conversion method """ + import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/sagemaker_converters/sagemaker_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/sagemaker_converters/sagemaker_to_sa_vector.py index 880361673..966985806 100644 --- a/src/superannotate/lib/app/input_converters/converters/sagemaker_converters/sagemaker_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/sagemaker_converters/sagemaker_to_sa_vector.py @@ -1,6 +1,7 @@ """ Sagemaker to SA conversion method """ + import json import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_to_sa_vector.py index 92a04f85b..9678b568d 100644 --- a/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/supervisely_converters/supervisely_to_sa_vector.py @@ -1,6 +1,7 @@ """ Supervisely to SA conversion method """ + import json import logging import threading @@ -92,9 +93,11 @@ def supervisely_to_sa(json_files, class_id_map, task, output_dir): elif obj["geometryType"] == "bitmap": for ppoints in _base64_to_polygon(obj["bitmap"]["data"]): points = [ - x + obj["bitmap"]["origin"][0] - if i % 2 == 0 - else x + obj["bitmap"]["origin"][1] + ( + x + obj["bitmap"]["origin"][0] + if i % 2 == 0 + else x + obj["bitmap"]["origin"][1] + ) for i, x in enumerate(ppoints) ] instance_type = "polygon" diff --git a/src/superannotate/lib/app/input_converters/converters/vgg_converters/vgg_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/vgg_converters/vgg_to_sa_vector.py index e826e7529..6cb1b8993 100644 --- a/src/superannotate/lib/app/input_converters/converters/vgg_converters/vgg_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/vgg_converters/vgg_to_sa_vector.py @@ -1,6 +1,7 @@ """ VGG to SA conversion method. """ + import json import logging import threading @@ -59,10 +60,8 @@ def vgg_to_sa(json_data, task, output_dir): instances = img["regions"] for instance in instances: if "type" not in instance["region_attributes"].keys(): - raise KeyError( - "'VGG' JSON should contain 'type' key which will \ - be category name. Please correct JSON file." - ) + raise KeyError("'VGG' JSON should contain 'type' key which will \ + be category name. Please correct JSON file.") if not isinstance(instance["region_attributes"]["type"], str): raise ValueError("Wrong attribute was choosen for 'type' attribute.") diff --git a/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_pixel.py b/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_pixel.py index 0714307e7..96fc02f2e 100644 --- a/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_pixel.py +++ b/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_pixel.py @@ -1,6 +1,7 @@ """ VOC to SA conversion method """ + import logging import cv2 diff --git a/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_vector.py index 67d823f34..3f42ed4b3 100644 --- a/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/voc_converters/voc_to_sa_vector.py @@ -1,6 +1,7 @@ """ VOC to SA conversion method """ + import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/vott_converters/vott_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/vott_converters/vott_to_sa_vector.py index 31a9d8ee0..f7bcc8ffd 100644 --- a/src/superannotate/lib/app/input_converters/converters/vott_converters/vott_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/vott_converters/vott_to_sa_vector.py @@ -1,6 +1,7 @@ """ VoTT to SA conversion method """ + import json import logging import threading diff --git a/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_strategies.py b/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_strategies.py index 2fb23797e..6dffbbe76 100644 --- a/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_strategies.py +++ b/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_strategies.py @@ -1,5 +1,5 @@ -""" -""" +""" """ + import numpy as np from ....common import write_to_json diff --git a/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_to_sa_vector.py b/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_to_sa_vector.py index dd90f1492..dac51598c 100644 --- a/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_to_sa_vector.py +++ b/src/superannotate/lib/app/input_converters/converters/yolo_converters/yolo_to_sa_vector.py @@ -1,6 +1,7 @@ """ YOLO to SA conversion method """ + import logging import threading from glob import glob diff --git a/src/superannotate/lib/app/input_converters/export_from_sa_conversions.py b/src/superannotate/lib/app/input_converters/export_from_sa_conversions.py index 1c4119eed..369cd323d 100644 --- a/src/superannotate/lib/app/input_converters/export_from_sa_conversions.py +++ b/src/superannotate/lib/app/input_converters/export_from_sa_conversions.py @@ -2,6 +2,7 @@ Module which will convert from superannotate annotation format to other annotation formats """ + import copy import json import logging diff --git a/src/superannotate/lib/app/input_converters/import_to_sa_conversions.py b/src/superannotate/lib/app/input_converters/import_to_sa_conversions.py index e02ea9a0c..9e68e7f8e 100644 --- a/src/superannotate/lib/app/input_converters/import_to_sa_conversions.py +++ b/src/superannotate/lib/app/input_converters/import_to_sa_conversions.py @@ -2,6 +2,7 @@ Module which will convert from other annotation formats to superannotate annotation format """ + import logging import shutil from pathlib import Path diff --git a/src/superannotate/lib/app/input_converters/sa_conversion.py b/src/superannotate/lib/app/input_converters/sa_conversion.py index 0c1016ed6..de2e0244a 100644 --- a/src/superannotate/lib/app/input_converters/sa_conversion.py +++ b/src/superannotate/lib/app/input_converters/sa_conversion.py @@ -1,7 +1,6 @@ import logging import shutil - logger = logging.getLogger("sa") diff --git a/src/superannotate/lib/app/interface/base_interface.py b/src/superannotate/lib/app/interface/base_interface.py index 699b74a23..9b25b2362 100644 --- a/src/superannotate/lib/app/interface/base_interface.py +++ b/src/superannotate/lib/app/interface/base_interface.py @@ -9,6 +9,7 @@ from pathlib import Path from types import FunctionType from typing import Iterable +from typing import Optional from typing import Sized import lib.core as constants @@ -18,19 +19,20 @@ from lib.core.entities.base import ConfigEntity from lib.core.entities.base import TokenStr from lib.core.exceptions import AppException -from lib.core.pydantic_v1 import ErrorWrapper -from lib.core.pydantic_v1 import ValidationError from lib.infrastructure.controller import Controller from lib.infrastructure.utils import extract_project_folder_inputs from lib.infrastructure.validators import wrap_error from mixpanel import Mixpanel +from pydantic import ValidationError class BaseInterfaceFacade: REGISTRY = [] @validate_arguments - def __init__(self, token: TokenStr = None, config_path: str = None): + def __init__( + self, token: Optional[TokenStr] = None, config_path: Optional[str] = None + ): try: if token: config = ConfigEntity(SA_TOKEN=token) @@ -80,10 +82,7 @@ def _retrieve_configs_from_json(path: Path) -> typing.Union[ConfigEntity]: try: config = ConfigEntity(SA_TOKEN=token) except ValidationError: - raise ValidationError( - [ErrorWrapper(ValueError("Invalid token."), loc="token")], - model=ConfigEntity, - ) + raise AppException("Invalid token.") host = json_data.get("main_endpoint") verify_ssl = json_data.get("ssl_verify") if host: diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 7a37795d4..e8cf6a456 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -17,6 +17,9 @@ from typing import Tuple from typing import Union +from pydantic import Field +from pydantic import StringConstraints +from typing_extensions import Annotated from typing_extensions import Literal if sys.version_info < (3, 11): @@ -27,6 +30,8 @@ import boto3 from tqdm import tqdm +from pydantic import ValidationError +from pydantic import TypeAdapter import lib.core as constants from lib.infrastructure.controller import Controller @@ -61,10 +66,6 @@ from lib.core.exceptions import AppException from lib.core.types import PriorityScoreEntity from lib.core.types import Project -from lib.core.pydantic_v1 import ValidationError -from lib.core.pydantic_v1 import constr -from lib.core.pydantic_v1 import conlist -from lib.core.pydantic_v1 import parse_obj_as from lib.infrastructure.annotation_adapter import BaseMultimodalAnnotationAdapter from lib.infrastructure.annotation_adapter import MultimodalSmallAnnotationAdapter from lib.infrastructure.annotation_adapter import MultimodalLargeAnnotationAdapter @@ -80,7 +81,7 @@ logger = logging.getLogger("sa") -NotEmptyStr = constr(strict=True, min_length=1) +NotEmptyStr = Annotated[str, StringConstraints(strict=True, min_length=1)] PROJECT_STATUS = Literal["NotStarted", "InProgress", "Completed", "OnHold"] @@ -149,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, @@ -161,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, @@ -297,8 +298,8 @@ class SAClient(BaseInterfaceFacade, metaclass=TrackableMeta): def __init__( self, - token: str = None, - config_path: str = None, + token: Optional[str] = None, + config_path: Optional[str] = None, ): super().__init__(token, config_path) @@ -340,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 @@ -395,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. @@ -420,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 @@ -513,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, ): """ @@ -1073,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 @@ -1183,12 +1186,12 @@ def create_project( project_name: NotEmptyStr, project_description: NotEmptyStr, project_type: PROJECT_TYPE, - settings: List[Setting] = None, - classes: List[AnnotationClassEntity] = None, - workflows: Any = None, - instructions_link: str = None, - workflow: str = None, - form: dict = 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, ): """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. @@ -1231,7 +1234,7 @@ def create_project( ) ) if settings: - settings = parse_obj_as(List[SettingEntity], settings) + settings = TypeAdapter(List[SettingEntity]).validate_python(settings) else: settings = [] if ProjectType(project_type) == ProjectType.MULTIMODAL: @@ -1245,7 +1248,7 @@ def create_project( ) settings.append(SettingEntity(attribute="TemplateState", value=1)) if classes: - classes = parse_obj_as(List[AnnotationClassEntity], classes) + classes = TypeAdapter(List[AnnotationClassEntity]).validate_python(classes) project_entity = entities.ProjectEntity( name=project_name, description=project_description, @@ -1802,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", @@ -1817,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", @@ -1876,7 +1879,8 @@ def get_project_settings(self, project: Union[NotEmptyStr, dict]): project = self.controller.projects.get_by_name(project_name).data settings = self.controller.projects.list_settings(project).data settings = [ - SettingsSerializer(attribute.dict()).serialize() for attribute in settings + SettingsSerializer(attribute.model_dump()).serialize() + for attribute in settings ] return settings @@ -1997,6 +2001,7 @@ def get_annotation_class( project="classes", annotation_class="Example_class" ) + Response Example: :: @@ -2052,16 +2057,19 @@ 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:: - 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. + 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. + :param project: The name or ID of the project. :type project: Union[str, int] @@ -2069,41 +2077,112 @@ 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 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) - + 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) @@ -2120,8 +2199,11 @@ def update_annotation_class( annotation_class["attribute_groups"] = attribute_groups try: # validate annotation class - annotation_class = WMAnnotationClassEntity.parse_obj( - BaseSerializer(annotation_class).serialize() + annotation_class = TypeAdapter(WMAnnotationClassEntity).validate_python( + BaseSerializer( + TypeAdapter(AnnotationClassEntity).validate_python(annotation_class) + ).serialize(), + by_name=True, ) except ValidationError as e: raise AppException(wrap_error(e)) @@ -2135,7 +2217,9 @@ def update_annotation_class( if response.errors: raise AppException(response.errors) - return BaseSerializer(response.data).serialize(by_alias=False) + return BaseSerializer(response.data.model_dump(mode="python")).serialize( + by_alias=False, use_enum_names=False + ) def set_project_status(self, project: NotEmptyStr, status: PROJECT_STATUS): """Set project status @@ -2679,7 +2763,7 @@ def prepare_export( def delete_exports( self, project: Union[NotEmptyStr, int], - exports: Union[List[int], List[str], Literal["*"]], + exports: Union[List[Union[int, str]], Literal["*"]], ): """Delete one or more exports from the specified project. The exports argument accepts a list of export names or export IDs. The special value “*” means delete all exports. @@ -2730,7 +2814,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. @@ -2985,7 +3069,9 @@ def create_annotation_class( """ attribute_groups = ( - list(map(lambda x: x.dict(), attribute_groups)) if attribute_groups else [] + list(map(lambda x: x.model_dump(), attribute_groups)) + if attribute_groups + else [] ) try: annotation_class = AnnotationClassEntity( @@ -2997,6 +3083,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 @@ -3105,7 +3195,9 @@ def create_annotation_classes_from_classes_json( with open(classes_json, encoding="utf-8") as f: classes_json = json.load(f) try: - annotation_classes = parse_obj_as(List[AnnotationClassEntity], classes_json) + annotation_classes = TypeAdapter( + List[AnnotationClassEntity] + ).validate_python(classes_json) except ValidationError as _: raise AppException("Couldn't validate annotation classes.") project = self.controller.projects.get_by_name(project).data @@ -3160,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. @@ -3289,7 +3381,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", ): @@ -3455,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. @@ -3564,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. @@ -3767,7 +3859,7 @@ def validate_annotations( def add_contributors_to_project( self, project: NotEmptyStr, - emails: conlist(EmailStr, min_items=1), + emails: Annotated[List[EmailStr], Field(min_length=1)], role: str, ) -> Tuple[List[str], List[str]]: """Add contributors to project. @@ -3802,7 +3894,9 @@ def add_contributors_to_project( return response.data def invite_contributors_to_team( - self, emails: conlist(EmailStr, min_items=1), admin: bool = False + self, + emails: Annotated[List[EmailStr], Field(min_length=1)], + admin: bool = False, ) -> Tuple[List[str], List[str]]: """Invites contributors to the team. @@ -3920,7 +4014,7 @@ def upload_priority_scores( :return: lists of uploaded, skipped items :rtype: tuple (2 members) of lists of strs """ - scores = parse_obj_as(List[PriorityScoreEntity], scores) + scores = TypeAdapter(List[PriorityScoreEntity]).validate_python(scores) project, folder = self.controller.get_project_folder(project) project_folder_name = project.name + "" if folder.is_root else f"/{folder.name}" response = self.controller.projects.upload_priority_scores( @@ -4146,8 +4240,8 @@ def get_item_metadata( def search_items( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], - name_contains: NotEmptyStr = None, - annotation_status: str = None, + name_contains: Optional[NotEmptyStr] = None, + annotation_status: Optional[str] = None, annotator_email: Optional[NotEmptyStr] = None, qa_email: Optional[NotEmptyStr] = None, recursive: bool = False, @@ -4270,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, ): """ @@ -4457,7 +4551,7 @@ def list_items( def list_projects( self, *, - include: List[Literal["custom_fields"]] = None, + include: Optional[List[Literal["custom_fields"]]] = None, **filters, ): """ @@ -4546,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", @@ -4568,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, @@ -4583,8 +4677,10 @@ def list_projects( def attach_items( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], - attachments: Union[NotEmptyStr, Path, conlist(Attachment, min_items=1)], - annotation_status: str = None, + attachments: Union[ + NotEmptyStr, Path, Annotated[List[Attachment], Field(min_length=1)] + ], + annotation_status: Optional[str] = None, ): """ Link items from external storage to SuperAnnotate using URLs. @@ -4635,7 +4731,9 @@ def attach_items( ) try: - attachments = parse_obj_as(List[AttachmentEntity], attachments) + attachments = TypeAdapter(List[AttachmentEntity]).validate_python( + attachments + ) unique_attachments = set(attachments) duplicate_attachments = [ item @@ -4649,7 +4747,9 @@ def attach_items( ) = get_name_url_duplicated_from_csv(attachments) if duplicate_attachments: logger.info("Dropping duplicates.") - unique_attachments = parse_obj_as(List[AttachmentEntity], unique_attachments) + unique_attachments = TypeAdapter(List[AttachmentEntity]).validate_python( + unique_attachments + ) uploaded, fails, duplicated = [], [], [] _unique_attachments = [] @@ -4948,10 +5048,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. @@ -5170,7 +5270,7 @@ def get_custom_fields(self, project: NotEmptyStr): return response.data def delete_custom_fields( - self, project: NotEmptyStr, fields: conlist(str, min_items=1) + self, project: NotEmptyStr, fields: Annotated[List[str], Field(min_length=1)] ): """Remove custom fields from a project’s custom metadata schema. @@ -5228,7 +5328,7 @@ def delete_custom_fields( def upload_custom_values( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], - items: conlist(Dict[str, dict], min_items=1), + items: Annotated[List[Dict[str, dict]], Field(min_length=1)], ): """ Attach custom metadata to items. @@ -5303,7 +5403,7 @@ def upload_custom_values( def delete_custom_values( self, project: Union[NotEmptyStr, int, Tuple[int, int], Tuple[str, str]], - items: conlist(Dict[str, List[str]], min_items=1), + items: Annotated[List[Dict[str, List[str]]], Field(min_length=1)], ): """ Remove custom data from items @@ -5587,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", @@ -5596,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/app/interface/types.py b/src/superannotate/lib/app/interface/types.py index 29a563c1e..0ae182fb4 100644 --- a/src/superannotate/lib/app/interface/types.py +++ b/src/superannotate/lib/app/interface/types.py @@ -1,47 +1,29 @@ +import re from functools import wraps -from typing import Union -from lib.core.enums import BaseTitledEnum from lib.core.exceptions import AppException -from lib.core.pydantic_v1 import constr -from lib.core.pydantic_v1 import errors -from lib.core.pydantic_v1 import pydantic_validate_arguments -from lib.core.pydantic_v1 import PydanticTypeError -from lib.core.pydantic_v1 import StrictStr -from lib.core.pydantic_v1 import StrRegexError -from lib.core.pydantic_v1 import ValidationError from lib.infrastructure.validators import wrap_error +from pydantic import AfterValidator +from pydantic import validate_call as pydantic_validate_arguments +from pydantic import ValidationError +from typing_extensions import Annotated +EMAIL_PATTERN = re.compile( + r"^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+" + r"(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*" + r"@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" +) -class EnumMemberError(PydanticTypeError): - code = "enum" - def __str__(self) -> str: - enum_values = list(self.enum_values) # noqa - if isinstance(enum_values[0], BaseTitledEnum): - permitted = ", ".join(str(v.name) for v in enum_values) # type: ignore - else: - permitted = ", ".join(f"'{str(v.value)}'" for v in enum_values) # type: ignore - return f"Available values are: {permitted}" +def _validate_email(value: str) -> str: + """Validate email format.""" + if not EMAIL_PATTERN.match(value): + raise ValueError("Invalid email") + return value -errors.EnumMemberError = EnumMemberError - - -class EmailStr(StrictStr): - @classmethod - def validate(cls, value: Union[str]) -> Union[str]: - try: - constr( - regex=r"^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)" - r"*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}" - r"[a-zA-Z0-9])?)*$" - ).validate( # noqa - value - ) - except StrRegexError: - raise ValueError("Invalid email") - return value +EmailStr = Annotated[str, AfterValidator(_validate_email)] def validate_arguments(func): diff --git a/src/superannotate/lib/app/serializers.py b/src/superannotate/lib/app/serializers.py index 407e8093c..afa615145 100644 --- a/src/superannotate/lib/app/serializers.py +++ b/src/superannotate/lib/app/serializers.py @@ -6,7 +6,7 @@ import lib.core as constance from lib.core.entities import BaseEntity -from lib.core.pydantic_v1 import BaseModel +from pydantic import BaseModel class BaseSerializer: @@ -14,11 +14,20 @@ def __init__(self, entity: BaseEntity): self._entity = entity @staticmethod - def _fill_enum_values(data: dict): + def _fill_enum_values(data: dict, by_name: bool = True): if isinstance(data, dict): for key, value in data.items(): - if isinstance(value, Enum): - data[key] = value.name + if isinstance(value, list): + for v in value: + BaseSerializer._fill_enum_values(v, by_name) + elif isinstance(value, Enum): + if by_name: + data[key] = value.name + else: + data[key] = value.value + elif isinstance(data, list): + for val in data: + BaseSerializer._fill_enum_values(val, by_name) return data def serialize( @@ -27,7 +36,8 @@ def serialize( by_alias: bool = True, flat: bool = False, exclude: Set[str] = None, - exclude_unset=False, + exclude_unset: bool = False, + use_enum_names: bool = True, ): return self._fill_enum_values( self._serialize( @@ -37,7 +47,8 @@ def serialize( flat, exclude=exclude, exclude_unset=exclude_unset, - ) + ), + by_name=use_enum_names, ) @staticmethod @@ -58,17 +69,17 @@ def _serialize( if fields: if len(fields) == 1: if flat: - return entity.dict( + return entity.model_dump( include=fields, by_alias=by_alias, exclude=exclude, **kwargs )[next(iter(fields))] - return entity.dict( + return entity.model_dump( include=fields, by_alias=by_alias, exclude=exclude, **kwargs ) - return entity.dict( + return entity.model_dump( include=fields, by_alias=by_alias, exclude=exclude, **kwargs ) - return entity.dict(by_alias=by_alias, exclude=exclude, **kwargs) - return entity.to_dict() + return entity.model_dump(by_alias=by_alias, exclude=exclude, **kwargs) + return entity.model_dump() @classmethod def serialize_iterable( @@ -90,8 +101,7 @@ def serialize_iterable( return serialized_data -class TeamSerializer(BaseSerializer): - ... +class TeamSerializer(BaseSerializer): ... # noqa E701 class ProjectSerializer(BaseSerializer): @@ -115,7 +125,8 @@ def serialize( to_exclude[field] = True if self._entity.classes: self._entity.classes = [ - i.dict(by_alias=True, exclude_unset=True) for i in self._entity.classes + i.model_dump(by_alias=True, exclude_unset=True) + for i in self._entity.classes ] data = super().serialize(fields, by_alias, flat, to_exclude) if data.get("settings"): @@ -176,14 +187,13 @@ def serialize(self): return self.data -class ItemSerializer(BaseSerializer): - ... +class ItemSerializer(BaseSerializer): ... # noqa E701 class EntitySerializer: @classmethod def serialize( - cls, data: Union[BaseModel, List[BaseModel]], **kwargs + cls, data: Union[BaseModel, List], **kwargs ) -> Union[List[dict], dict]: if isinstance(data, (list, set)): for idx, item in enumerate(data): @@ -193,4 +203,4 @@ def serialize( nested_model, "fill_enum_values", False ): setattr(data, key, cls.serialize(nested_model, **kwargs)) - return data.dict(**kwargs) + return data.model_dump(**kwargs) diff --git a/src/superannotate/lib/core/entities/__init__.py b/src/superannotate/lib/core/entities/__init__.py index 50aa59445..a344186ee 100644 --- a/src/superannotate/lib/core/entities/__init__.py +++ b/src/superannotate/lib/core/entities/__init__.py @@ -27,7 +27,6 @@ from lib.core.entities.work_managament import WMAnnotationClassEntity from lib.core.entities.work_managament import WMProjectUserEntity - __all__ = [ # base "ConfigEntity", diff --git a/src/superannotate/lib/core/entities/base.py b/src/superannotate/lib/core/entities/base.py index 26285ceb8..f3e1ae8da 100644 --- a/src/superannotate/lib/core/entities/base.py +++ b/src/superannotate/lib/core/entities/base.py @@ -1,61 +1,64 @@ import re from datetime import datetime +from typing import Annotated +from typing import Literal from typing import Optional from typing import Union from lib.core import BACKEND_URL from lib.core import LOG_FILE_LOCATION -from lib.core.pydantic_v1 import BaseModel -from lib.core.pydantic_v1 import Extra -from lib.core.pydantic_v1 import Field -from lib.core.pydantic_v1 import Literal -from lib.core.pydantic_v1 import parse_datetime -from lib.core.pydantic_v1 import StrictStr +from pydantic import AfterValidator +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 = ( "does not match expected format YYYY-MM-DDTHH:MM:SS.fffZ" ) DATE_REGEX = r"\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d{3})Z" -try: - from pydantic import AbstractSetIntStr # noqa - from pydantic import MappingIntStrAny # noqa - -except ImportError: - pass _missing = object() -from lib.core.pydantic_v1 import Color -from lib.core.pydantic_v1 import ColorType -from lib.core.pydantic_v1 import validator + +def _validate_hex_color(v: str) -> str: + """Convert color to hex format.""" + color = Color(v) + return "#{:02X}{:02X}{:02X}".format(*color.as_rgb_tuple()[:3]) -class HexColor(BaseModel): - __root__: ColorType +HexColor = Annotated[str, AfterValidator(_validate_hex_color)] - @validator("__root__", pre=True) - def validate_color(cls, v): - return "#{:02X}{:02X}{:02X}".format(*Color(v).as_rgb_tuple()) +def _validate_string_date(v: Union[datetime, str]) -> str: + """Convert datetime to string format.""" + if isinstance(v, str): + return v + return v.isoformat().split("+")[0] + ".000Z" -class StringDate(datetime): - @classmethod - def __get_validators__(cls): - yield parse_datetime - yield cls.validate - @classmethod - def validate(cls, v: datetime): - v = v.isoformat().split("+")[0] + ".000Z" +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): + 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): - id: Optional[int] - name: str + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + id: Optional[int] = None + name: str class TimedBaseModel(BaseModel): @@ -68,24 +71,25 @@ class TimedBaseModel(BaseModel): class BaseItemEntity(TimedBaseModel): - id: Optional[int] - name: Optional[str] - folder_id: Optional[int] + model_config = ConfigDict(extra="allow") + + id: Optional[int] = None + name: Optional[str] = None + folder_id: Optional[int] = None path: Optional[str] = Field( None, description="Item’s path in SuperAnnotate project" ) - url: Optional[str] = Field(description="Publicly available HTTP address") + url: Optional[str] = Field(None, description="Publicly available HTTP address") annotator_email: Optional[str] = Field(None, description="Annotator email") qa_email: Optional[str] = Field(None, description="QA email") annotation_status: Optional[Union[int, str]] = Field( None, description="Item annotation status" ) - entropy_value: Optional[float] = Field(description="Priority score of given item") - custom_metadata: Optional[dict] - assignments: Optional[list] = Field([]) - - class Config: - extra = Extra.allow + entropy_value: Optional[float] = Field( + None, description="Priority score of given item" + ) + custom_metadata: Optional[dict] = None + assignments: Optional[list] = Field(default_factory=list) def __hash__(self): return hash(self.name) @@ -106,20 +110,23 @@ def map_fields(entity: dict) -> dict: return entity -class TokenStr(StrictStr): - regex = r"^[-.@_A-Za-z0-9]+=\d+$" +TOKEN_PATTERN = re.compile(r"^[-.@_A-Za-z0-9]+=\d+$") + + +def _validate_token(value: str) -> str: + """Validate token format.""" + if not TOKEN_PATTERN.match(value): + raise ValueError("Invalid token.") + return value - @classmethod - def validate(cls, value: Union[str]) -> Union[str]: - if cls.curtail_length and len(value) > cls.curtail_length: - value = value[: cls.curtail_length] - if cls.regex: - if not re.match(cls.regex, value): - raise ValueError("Invalid token.") - return value + +# Pydantic v2 compatible TokenStr using Annotated +TokenStr = Annotated[str, AfterValidator(_validate_token)] class ConfigEntity(BaseModel): + model_config = ConfigDict(extra="ignore") + API_TOKEN: TokenStr = Field(alias="SA_TOKEN") API_URL: str = Field(alias="SA_URL", default=BACKEND_URL) LOGGING_LEVEL: Literal[ @@ -127,10 +134,7 @@ class ConfigEntity(BaseModel): ] = "INFO" LOGGING_PATH: str = f"{LOG_FILE_LOCATION}" VERIFY_SSL: bool = True - ANNOTATION_CHUNK_SIZE = 5000 - ITEM_CHUNK_SIZE = 2000 - MAX_THREAD_COUNT = 4 - MAX_COROUTINE_COUNT = 8 - - class Config: - extra = Extra.ignore + ANNOTATION_CHUNK_SIZE: int = 5000 + ITEM_CHUNK_SIZE: int = 2000 + MAX_THREAD_COUNT: int = 4 + MAX_COROUTINE_COUNT: int = 8 diff --git a/src/superannotate/lib/core/entities/classes.py b/src/superannotate/lib/core/entities/classes.py index 7b90320b4..aa251c990 100644 --- a/src/superannotate/lib/core/entities/classes.py +++ b/src/superannotate/lib/core/entities/classes.py @@ -5,13 +5,11 @@ from lib.core.entities.base import HexColor from lib.core.entities.base import TimedBaseModel -from lib.core.enums import BaseTitledEnum from lib.core.enums import ClassTypeEnum -from lib.core.pydantic_v1 import Extra -from lib.core.pydantic_v1 import Field -from lib.core.pydantic_v1 import StrictInt -from lib.core.pydantic_v1 import StrictStr - +from pydantic import ConfigDict +from pydantic import Field +from pydantic import StrictInt +from pydantic import StrictStr DATE_REGEX = r"\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d(?:\.\d{3})Z" DATE_TIME_FORMAT_ERROR_MESSAGE = ( @@ -28,52 +26,46 @@ class GroupTypeEnum(str, Enum): class Attribute(TimedBaseModel): - id: Optional[StrictInt] - group_id: Optional[StrictInt] - project_id: Optional[StrictInt] - name: Optional[StrictStr] - default: Any + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + id: Optional[StrictInt] = None + group_id: Optional[StrictInt] = None + project_id: Optional[StrictInt] = None + name: Optional[StrictStr] = None + default: Any = None def __hash__(self): return hash(f"{self.id}{self.group_id}{self.name}") class AttributeGroup(TimedBaseModel): - id: Optional[StrictInt] - group_type: Optional[GroupTypeEnum] - class_id: Optional[StrictInt] - name: Optional[StrictStr] - isRequired: bool = Field(default=False) - attributes: Optional[List[Attribute]] - default_value: Any + model_config = ConfigDict(extra="ignore", use_enum_values=True) - class Config: - extra = Extra.ignore - use_enum_values = True + id: Optional[StrictInt] = None + group_type: Optional[GroupTypeEnum] = None + class_id: Optional[StrictInt] = None + name: Optional[StrictStr] = None + isRequired: bool = Field(default=False) + attributes: Optional[List[Attribute]] = None + default_value: Any = None def __hash__(self): return hash(f"{self.id}{self.class_id}{self.name}") class AnnotationClassEntity(TimedBaseModel): - id: Optional[StrictInt] - project_id: Optional[StrictInt] + model_config = ConfigDict( + extra="ignore", + validate_assignment=True, + json_encoders={HexColor: lambda v: v.__root__, Enum: lambda v: v.value}, + ) + + id: Optional[StrictInt] = None + project_id: Optional[StrictInt] = None type: ClassTypeEnum = ClassTypeEnum.OBJECT name: StrictStr color: HexColor - attribute_groups: List[AttributeGroup] = [] + attribute_groups: List[AttributeGroup] = Field(default_factory=list) def __hash__(self): return hash(f"{self.id}{self.type}{self.name}") - - class Config: - extra = Extra.ignore - json_encoders = { - HexColor: lambda v: v.__root__, - BaseTitledEnum: lambda v: v.value, - } - validate_assignment = True - use_enum_names = True diff --git a/src/superannotate/lib/core/entities/folder.py b/src/superannotate/lib/core/entities/folder.py index 4a5113c8b..0d129fa41 100644 --- a/src/superannotate/lib/core/entities/folder.py +++ b/src/superannotate/lib/core/entities/folder.py @@ -1,44 +1,43 @@ -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 +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import model_validator class FolderUserEntity(BaseModel): + model_config = ConfigDict(extra="ignore") + 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): - id: Optional[int] - name: Optional[str] - status: Optional[FolderStatus] - project_id: Optional[int] - team_id: Optional[int] + model_config = ConfigDict(extra="ignore") + + id: Optional[int] = None + name: Optional[str] = None + status: Optional[FolderStatus] = None + project_id: Optional[int] = None + team_id: Optional[int] = None is_root: Optional[bool] = False contributors: Optional[List[FolderUserEntity]] = Field( default_factory=list, alias="folderUsers" ) + completedCount: Optional[int] = None - completedCount: Optional[int] - - @root_validator(pre=True) + @model_validator(mode="before") + @classmethod def normalize_folder_users(cls, values: dict) -> dict: + if not isinstance(values, dict): + return values folder_users = values.get("folderUsers") if not folder_users: return values @@ -58,7 +57,3 @@ def normalize_folder_users(cls, values: dict) -> dict: values["folderUsers"] = normalized return values - - class Config: - extra = Extra.ignore - allow_population_by_field_name = True diff --git a/src/superannotate/lib/core/entities/integrations.py b/src/superannotate/lib/core/entities/integrations.py index e59e0935e..98a7df560 100644 --- a/src/superannotate/lib/core/entities/integrations.py +++ b/src/superannotate/lib/core/entities/integrations.py @@ -1,12 +1,7 @@ from lib.core.entities.base import TimedBaseModel from lib.core.enums import IntegrationTypeEnum - -try: - from pydantic.v1 import Extra - from pydantic.v1 import Field -except ImportError: - from pydantic import Extra - from pydantic import Field +from pydantic import ConfigDict +from pydantic import Field class IntegrationEntity(TimedBaseModel): @@ -15,6 +10,4 @@ class IntegrationEntity(TimedBaseModel): name: str type: IntegrationTypeEnum = Field(None, alias="source") root: str = Field(None, alias="bucket_name") - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") diff --git a/src/superannotate/lib/core/entities/items.py b/src/superannotate/lib/core/entities/items.py index 1beff24cf..13cbf74ae 100644 --- a/src/superannotate/lib/core/entities/items.py +++ b/src/superannotate/lib/core/entities/items.py @@ -5,78 +5,61 @@ from lib.core.entities.project import TimedBaseModel from lib.core.enums import ApprovalStatus from lib.core.enums import ProjectType -from lib.core.pydantic_v1 import Extra -from lib.core.pydantic_v1 import Field +from pydantic import ConfigDict +from pydantic import Field class ImageEntity(BaseItemEntity): approval_status: Optional[ApprovalStatus] = Field(None) - is_pinned: Optional[bool] - meta: Optional[dict] - - class Config: - extra = Extra.ignore + is_pinned: Optional[bool] = Field(None) + meta: Optional[dict] = Field(None) + model_config = ConfigDict(extra="ignore") class CategoryEntity(TimedBaseModel): id: int value: str = Field(None, alias="name") - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class ProjectCategoryEntity(TimedBaseModel): id: int name: str project_id: int - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class MultiModalItemCategoryEntity(TimedBaseModel): id: int = Field(None, alias="category_id") value: str = Field(None, alias="category_name") - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class MultiModalItemEntity(BaseItemEntity): - categories: Optional[List[MultiModalItemCategoryEntity]] - - class Config: - extra = Extra.ignore + categories: Optional[List[MultiModalItemCategoryEntity]] = None + model_config = ConfigDict(extra="ignore") class VideoEntity(BaseItemEntity): approval_status: Optional[ApprovalStatus] = Field(None) - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class DocumentEntity(BaseItemEntity): approval_status: Optional[ApprovalStatus] = Field(None) - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class TiledEntity(BaseItemEntity): - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class ClassificationEntity(BaseItemEntity): - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") class PointCloudEntity(BaseItemEntity): - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") PROJECT_ITEM_ENTITY_MAP = { diff --git a/src/superannotate/lib/core/entities/multimodal_form.py b/src/superannotate/lib/core/entities/multimodal_form.py index 2c2c07701..ac89144db 100644 --- a/src/superannotate/lib/core/entities/multimodal_form.py +++ b/src/superannotate/lib/core/entities/multimodal_form.py @@ -29,6 +29,7 @@ # Or use the convenience function classes = generate_classes_from_form(form_json) """ + import random from typing import Any from typing import Dict @@ -313,6 +314,7 @@ def generate_classes(self) -> List[Dict[str, Any]]: excluded_components = { "button", "container", + "modal", "group", "divider", "grid", diff --git a/src/superannotate/lib/core/entities/project.py b/src/superannotate/lib/core/entities/project.py index 2e3eb7a42..ddae5964b 100644 --- a/src/superannotate/lib/core/entities/project.py +++ b/src/superannotate/lib/core/entities/project.py @@ -1,40 +1,21 @@ -import datetime import uuid from typing import Any from typing import List from typing import Optional from typing import Union -from lib.core.entities.base import BaseModel +from lib.core.entities.base import TimedBaseModel from lib.core.entities.classes import AnnotationClassEntity from lib.core.entities.work_managament import WMProjectUserEntity -from lib.core.enums import BaseTitledEnum from lib.core.enums import ProjectStatus from lib.core.enums import ProjectType -from lib.core.pydantic_v1 import Extra -from lib.core.pydantic_v1 import Field -from lib.core.pydantic_v1 import parse_datetime -from lib.core.pydantic_v1 import StrictBool -from lib.core.pydantic_v1 import StrictFloat -from lib.core.pydantic_v1 import StrictInt -from lib.core.pydantic_v1 import StrictStr - - -class StringDate(datetime.datetime): - @classmethod - def __get_validators__(cls): - yield parse_datetime - yield cls.validate - - @classmethod - def validate(cls, v: datetime): - v = v.strftime("%Y-%m-%dT%H:%M:%S+00:00") - return v - - -class TimedBaseModel(BaseModel): - createdAt: Optional[StringDate] = None - updatedAt: Optional[StringDate] = None +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import StrictBool +from pydantic import StrictFloat +from pydantic import StrictInt +from pydantic import StrictStr class AttachmentEntity(BaseModel): @@ -42,91 +23,79 @@ class AttachmentEntity(BaseModel): url: str integration: Optional[str] = None integration_id: Optional[int] = None - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore") def __hash__(self): return hash(self.name) class StepEntity(BaseModel): - id: Optional[int] - project_id: Optional[int] - class_id: Optional[int] - className: Optional[str] - step: Optional[int] - tool: Optional[int] - attribute: List = tuple() + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + id: Optional[int] = None + project_id: Optional[int] = None + class_id: Optional[int] = None + className: Optional[str] = None + step: Optional[int] = None + tool: Optional[int] = None + attribute: List = Field(default_factory=list) def __copy__(self): return StepEntity(step=self.step, tool=self.tool, attribute=self.attribute) class SettingEntity(BaseModel): - id: Optional[int] - project_id: Optional[int] - attribute: str - value: Union[StrictStr, StrictInt, StrictFloat, StrictBool] # todo set any + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + id: Optional[int] = None + project_id: Optional[int] = None + attribute: str + value: Union[StrictStr, StrictInt, StrictFloat, StrictBool, None] = None def __copy__(self): return SettingEntity(attribute=self.attribute, value=self.value) class WorkflowEntity(TimedBaseModel): - id: Optional[int] - name: Optional[str] - type: Optional[str] - description: Optional[str] - raw_config: Optional[dict] + model_config = ConfigDict(extra="ignore") + + id: Optional[int] = None + name: Optional[str] = None + type: Optional[str] = None + description: Optional[str] = None + raw_config: Optional[dict] = None def is_system(self): return self.type == "system" - class Config: - extra = Extra.ignore - class ProjectEntity(TimedBaseModel): - id: Optional[int] - team_id: Optional[int] + model_config = ConfigDict(extra="ignore", use_enum_values=False) + + id: Optional[int] = None + team_id: Optional[int] = None name: str type: ProjectType - description: Optional[str] - instructions_link: Optional[str] - creator_id: Optional[str] - entropy_status: Optional[int] - sharing_status: Optional[int] - status: Optional[ProjectStatus] - folder_id: Optional[int] - workflow_id: Optional[int] - workflow: Optional[WorkflowEntity] - sync_status: Optional[int] - upload_state: Optional[int] - contributors: List[WMProjectUserEntity] = [] - settings: List[SettingEntity] = [] - classes: List[AnnotationClassEntity] = [] + description: Optional[str] = None + instructions_link: Optional[str] = None + creator_id: Optional[str] = None + entropy_status: Optional[int] = None + sharing_status: Optional[int] = None + status: Optional[ProjectStatus] = None + folder_id: Optional[int] = None + workflow_id: Optional[int] = None + workflow: Optional[WorkflowEntity] = None + sync_status: Optional[int] = None + upload_state: Optional[int] = None + contributors: List[WMProjectUserEntity] = Field(default_factory=list) + settings: List[SettingEntity] = Field(default_factory=list) + classes: List[AnnotationClassEntity] = Field(default_factory=list) item_count: Optional[int] = Field(None, alias="imageCount") completed_items_count: Optional[int] = Field(None, alias="completedImagesCount") root_folder_completed_items_count: Optional[int] = Field( None, alias="rootFolderCompletedImagesCount" ) - custom_fields: dict = {} - - class Config: - extra = Extra.ignore - use_enum_names = True - json_encoders = { - BaseTitledEnum: lambda v: v.value, - datetime.date: lambda v: v.isoformat(), - datetime.datetime: lambda v: v.isoformat(), - } + custom_fields: dict = Field(default_factory=dict) def __copy__(self): return ProjectEntity( @@ -148,35 +117,30 @@ def __eq__(self, other): class UserEntity(BaseModel): - id: Optional[str] - first_name: Optional[str] - last_name: Optional[str] - email: Optional[str] - user_role: Optional[int] + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + id: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + email: Optional[str] = None + user_role: Optional[int] = None class TeamEntity(BaseModel): - id: Optional[int] - name: Optional[str] - description: Optional[str] - type: Optional[str] - user_role: Optional[str] - is_default: Optional[bool] - users: Optional[List[UserEntity]] - pending_invitations: Optional[List[Any]] - creator_id: Optional[str] - owner_id: Optional[str] - scores: Optional[List[str]] - - class Config: - extra = Extra.ignore + model_config = ConfigDict(extra="ignore", coerce_numbers_to_str=True) + id: Optional[int] = None + name: Optional[str] = None + description: Optional[str] = None + type: Optional[str] = None + user_role: Optional[int] = None + is_default: Optional[bool] = None + users: Optional[List[UserEntity]] = None + pending_invitations: Optional[List[Any]] = None + creator_id: Optional[str] = None + owner_id: Optional[str] = None + scores: Optional[List[str]] = None -class CustomFieldEntity(BaseModel): - ... - class Config: - extra = Extra.allow +class CustomFieldEntity(BaseModel): + model_config = ConfigDict(extra="allow") diff --git a/src/superannotate/lib/core/entities/work_managament.py b/src/superannotate/lib/core/entities/work_managament.py index 54b3dce0c..7fd742c5a 100644 --- a/src/superannotate/lib/core/entities/work_managament.py +++ b/src/superannotate/lib/core/entities/work_managament.py @@ -12,14 +12,14 @@ from lib.core.enums import WMGroupTypeEnum from lib.core.enums import WMUserStateEnum from lib.core.exceptions import AppException -from lib.core.pydantic_v1 import BaseModel -from lib.core.pydantic_v1 import Extra -from lib.core.pydantic_v1 import Field -from lib.core.pydantic_v1 import parse_datetime -from lib.core.pydantic_v1 import root_validator -from lib.core.pydantic_v1 import StrictInt -from lib.core.pydantic_v1 import StrictStr -from lib.core.pydantic_v1 import validator +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import field_serializer +from pydantic import field_validator +from pydantic import model_validator +from pydantic import StrictInt +from pydantic import StrictStr class ProjectType(str, Enum): @@ -52,125 +52,99 @@ def __repr__(self): return self._name_ -class StringDate(datetime.datetime): - @classmethod - def __get_validators__(cls): - yield parse_datetime - yield cls.validate - - @classmethod - def validate(cls, v: datetime): - v = v.strftime("%Y-%m-%dT%H:%M:%S+00:00") +def _validate_string_date_wm(v: datetime.datetime) -> str: + """Convert datetime to string format for WM entities.""" + if isinstance(v, str): return v + return v.strftime("%Y-%m-%dT%H:%M:%S+00:00") class WMProjectEntity(TimedBaseModel): - id: Optional[int] - team_id: Optional[int] + model_config = ConfigDict(extra="ignore") + + id: Optional[int] = None + team_id: Optional[int] = None name: str type: ProjectType - description: Optional[str] - creator_id: Optional[str] - status: Optional[ProjectStatus] - workflow_id: Optional[int] - sync_status: Optional[int] - upload_state: Optional[str] - custom_fields: Optional[dict] = Field(dict(), alias="customField") - - @validator("custom_fields") + description: Optional[str] = None + creator_id: Optional[str] = None + status: Optional[ProjectStatus] = None + workflow_id: Optional[int] = None + sync_status: Optional[int] = None + upload_state: Optional[str] = None + custom_fields: Optional[dict] = Field(default_factory=dict, alias="customField") + + @field_validator("custom_fields", mode="before") + @classmethod def custom_fields_transformer(cls, v): if v and "custom_field_values" in v: return v.get("custom_field_values", {}) return {} - class Config: - extra = Extra.ignore - use_enum_names = True - - json_encoders = { - Enum: lambda v: v.value, - datetime.date: lambda v: v.isoformat(), - datetime.datetime: lambda v: v.isoformat(), - } - def __eq__(self, other): return self.id == other.id - def json(self, **kwargs): + def model_dump_json(self, **kwargs): if "exclude" not in kwargs: kwargs["exclude"] = {"custom_fields"} - return super().json(**kwargs) + return super().model_dump_json(**kwargs) class WMUserEntity(TimedBaseModel): - id: Optional[int] - team_id: Optional[int] - role: WMUserTypeEnum - email: Optional[str] - state: Optional[WMUserStateEnum] - custom_fields: Optional[dict] = Field(dict(), alias="customField") - - class Config: - extra = Extra.ignore - use_enum_names = True + model_config = ConfigDict(extra="ignore") - json_encoders = { - Enum: lambda v: v.value, - datetime.date: lambda v: v.isoformat(), - datetime.datetime: lambda v: v.isoformat(), - } + id: Optional[int] = None + team_id: Optional[int] = None + role: WMUserTypeEnum + email: Optional[str] = None + state: Optional[WMUserStateEnum] = None + custom_fields: Optional[dict] = Field(default_factory=dict, alias="customField") - @validator("custom_fields") + @field_validator("custom_fields", mode="before") + @classmethod def custom_fields_transformer(cls, v): if v and "custom_field_values" in v: return v.get("custom_field_values", {}) return {} - def json(self, **kwargs): + def model_dump_json(self, **kwargs): if "exclude" not in kwargs: kwargs["exclude"] = {"custom_fields"} - return super().json(**kwargs) + return super().model_dump_json(**kwargs) class WMProjectUserEntity(TimedBaseModel): - id: Optional[int] - team_id: Optional[int] - role: Optional[int] - email: Optional[str] - state: Optional[WMUserStateEnum] - custom_fields: Optional[dict] = Field(dict(), alias="customField") - permissions: Optional[dict] - categories: Optional[List[dict]] - - class Config: - extra = Extra.ignore - use_enum_names = True - - json_encoders = { - Enum: lambda v: v.value, - datetime.date: lambda v: v.isoformat(), - datetime.datetime: lambda v: v.isoformat(), - } - - @validator("custom_fields") + model_config = ConfigDict(extra="ignore") + + id: Optional[int] = None + team_id: Optional[int] = None + role: Optional[int] = None + email: Optional[str] = None + state: Optional[WMUserStateEnum] = None + custom_fields: Optional[dict] = Field(default_factory=dict, alias="customField") + permissions: Optional[dict] = None + categories: Optional[List[dict]] = None + + @field_validator("custom_fields", mode="before") + @classmethod def custom_fields_transformer(cls, v): if v and "custom_field_values" in v: return v.get("custom_field_values", {}) return {} - def json(self, **kwargs): + def model_dump_json(self, **kwargs): if "exclude" not in kwargs: kwargs["exclude"] = {"custom_fields"} - return super().json(**kwargs) + return super().model_dump_json(**kwargs) class WMScoreEntity(TimedBaseModel): id: int team_id: int name: str - description: Optional[str] + description: Optional[str] = None type: str - payload: Optional[dict] + payload: Optional[dict] = None class TelemetryScoreEntity(BaseModel): @@ -180,74 +154,69 @@ class TelemetryScoreEntity(BaseModel): user_id: str user_role: str score_id: int - value: Optional[Any] - weight: Optional[float] + value: Optional[Any] = None + weight: Optional[float] = None class ScoreEntity(TimedBaseModel): id: int name: str - value: Optional[Any] - weight: Optional[float] + value: Optional[Any] = None + weight: Optional[float] = None class ScorePayloadEntity(BaseModel): + model_config = ConfigDict(extra="forbid") + component_id: str - value: Any + value: Any = None weight: Optional[Union[float, int]] = 1.0 - class Config: - extra = Extra.forbid - - @validator("weight", pre=True, always=True) + @field_validator("weight", mode="before") + @classmethod def validate_weight(cls, v): if v is not None and (not isinstance(v, (int, float)) or v <= 0): raise AppException("Please provide a valid number greater than 0") return v - @root_validator() - def check_weight_and_value(cls, values): - value = values.get("value") - weight = values.get("weight") - if (weight is None and value is not None) or ( - weight is not None and value is None + @model_validator(mode="after") + def check_weight_and_value(self): + if (self.weight is None and self.value is not None) or ( + self.weight is not None and self.value is None ): raise AppException("Weight and Value must both be set or both be None.") - return values + return self class WMAttribute(TimedBaseModel): - id: Optional[StrictInt] - group_id: Optional[StrictInt] - project_id: Optional[StrictInt] - name: Optional[StrictStr] - default: Any + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + id: Optional[StrictInt] = None + group_id: Optional[StrictInt] = None + project_id: Optional[StrictInt] = None + name: Optional[StrictStr] = None + default: Any = None def __hash__(self): return hash(f"{self.id}{self.group_id}{self.name}") class WMAttributeGroup(TimedBaseModel): - id: Optional[StrictInt] - group_type: Optional[WMGroupTypeEnum] - class_id: Optional[StrictInt] - name: Optional[StrictStr] - isRequired: bool = Field(default=False, alias="is_required") - attributes: Optional[List[WMAttribute]] - default_value: Any + model_config = ConfigDict(extra="ignore") - class Config: - allow_population_by_field_name = True - extra = Extra.ignore + id: Optional[StrictInt] = None + group_type: WMGroupTypeEnum + class_id: Optional[StrictInt] = None + name: Optional[StrictStr] = None + isRequired: bool = Field(default=False, alias="is_required") + attributes: Optional[List[WMAttribute]] = None + default_value: Any = None def __hash__(self): return hash(f"{self.id}{self.class_id}{self.name}") - @validator("group_type", pre=True) - def validate_group_type(cls, v): + @classmethod + def _serialize_group_type(cls, v: Optional[WMGroupTypeEnum], _info): if v is None: return v if isinstance(v, WMGroupTypeEnum): @@ -264,48 +233,40 @@ def validate_group_type(cls, v): pass raise ValueError(f"Invalid group_type: {v}") - def dict(self, *args, **kwargs): - by_alias = kwargs.get("by_alias", False) - data = super().dict(*args, **kwargs) + @field_validator("group_type", mode="before") + @classmethod + def validate_group_type(cls, v): + return cls._serialize_group_type(v, None) - if by_alias and "group_type" in data: - if isinstance(data["group_type"], WMGroupTypeEnum): - data["group_type"] = data["group_type"].name - elif not by_alias and "group_type" in data: - if isinstance(data["group_type"], WMGroupTypeEnum): - data["group_type"] = data["group_type"].value - return data + @field_serializer("group_type", when_used="json") + def serialize_group_type(self, v: WMGroupTypeEnum): + return v.name class WMAnnotationClassEntity(TimedBaseModel): - id: Optional[StrictInt] - project_id: Optional[StrictInt] + model_config = ConfigDict( + extra="ignore", validate_assignment=True, arbitrary_types_allowed=True + ) + + id: Optional[StrictInt] = None + project_id: Optional[StrictInt] = None type: WMClassTypeEnum = WMClassTypeEnum.OBJECT name: StrictStr color: HexColor attribute_groups: List[WMAttributeGroup] = Field( - default=[], alias="attributeGroups" + default_factory=list, alias="attributeGroups" ) def __hash__(self): return hash(f"{self.id}{self.type}{self.name}") - class Config: - allow_population_by_field_name = True - extra = Extra.ignore - json_encoders = { - HexColor: lambda v: v.__root__, - # WMClassTypeEnum: lambda v: v.name, - } - validate_assignment = True - - def dict(self, *args, **kwargs): - data = super().dict(*args, **kwargs) - if "type" in data and isinstance(data["type"], WMClassTypeEnum): - data["type"] = data["type"].value - return data - - @validator("type", pre=True) + @field_serializer("type") + def serialize_type(self, v: WMClassTypeEnum, _info): + # API expects lowercase enum values (object, tag, etc.) + return v.value + + @field_validator("type", mode="before") + @classmethod def validate_type(cls, v): if isinstance(v, WMClassTypeEnum): return v diff --git a/src/superannotate/lib/core/enums.py b/src/superannotate/lib/core/enums.py index ebabcc153..a8d2ec797 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/src/superannotate/lib/core/plugin.py b/src/superannotate/lib/core/plugin.py index e0d52bef5..396039c25 100644 --- a/src/superannotate/lib/core/plugin.py +++ b/src/superannotate/lib/core/plugin.py @@ -82,7 +82,7 @@ def generate_thumb(self): thumbnail_size = (128, 96) background = Image.new("RGB", thumbnail_size, "black") image.thumbnail(thumbnail_size, Image.LANCZOS) - (w, h) = image.size + w, h = image.size background.paste( image, ((thumbnail_size[0] - w) // 2, (thumbnail_size[1] - h) // 2) ) diff --git a/src/superannotate/lib/core/pydantic_v1.py b/src/superannotate/lib/core/pydantic_v1.py deleted file mode 100644 index df3b621c6..000000000 --- a/src/superannotate/lib/core/pydantic_v1.py +++ /dev/null @@ -1,38 +0,0 @@ -from lib.core.utils import parse_version -from pydantic import VERSION - - -if parse_version(VERSION).major < 2: - import pydantic -else: - import pydantic.v1 as pydantic # noqa - -BaseModel = pydantic.BaseModel -Field = pydantic.Field -Extra = pydantic.Extra -ValidationError = pydantic.ValidationError -StrictStr = pydantic.StrictStr -StrictInt = pydantic.StrictInt -StrictBool = pydantic.StrictBool -StrictFloat = pydantic.StrictFloat -ErrorWrapper = pydantic.error_wrappers.ErrorWrapper -parse_obj_as = pydantic.parse_obj_as -is_namedtuple = pydantic.typing.is_namedtuple # noqa -Literal = pydantic.typing.Literal # noqa -ValueItems = pydantic.utils.ValueItems # noqa -ROOT_KEY = pydantic.utils.ROOT_KEY # noqa -sequence_like = pydantic.utils.sequence_like # noqa -validator = pydantic.validator # noqa -root_validator = pydantic.root_validator # noqa -constr = pydantic.constr # noqa -conlist = pydantic.conlist # noqa -parse_datetime = pydantic.datetime_parse.parse_datetime # noqa -Color = pydantic.color.Color # noqa -ColorType = pydantic.color.ColorType # noqa -validators = pydantic.validators # noqa -WrongConstantError = pydantic.errors.WrongConstantError -errors = pydantic.errors -PydanticTypeError = pydantic.errors.PydanticTypeError -pydantic_validate_arguments = pydantic.validate_arguments -StrRegexError = pydantic.errors.StrRegexError -create_model_from_typeddict = pydantic.annotated_types.create_model_from_typeddict diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index 554c02583..e79ebdecc 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -11,45 +11,36 @@ from lib.core.entities.work_managament import WMUserEntity from lib.core.enums import ProjectType from lib.core.exceptions import AppException -from lib.core.pydantic_v1 import BaseModel -from lib.core.pydantic_v1 import Extra -from lib.core.pydantic_v1 import Field +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field class Limit(BaseModel): - max_image_count: Optional[int] - remaining_image_count: int + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + max_image_count: Optional[int] = None + remaining_image_count: int class UserLimits(BaseModel): - user_limit: Optional[Limit] + model_config = ConfigDict(extra="ignore") + + user_limit: Optional[Limit] = None project_limit: Limit folder_limit: Limit - class Config: - extra = Extra.ignore - class UploadAnnotationAuthData(BaseModel): - access_key: str - secret_key: str - session_token: str + model_config = ConfigDict(extra="allow") + + access_key: str = Field(..., alias="accessKeyId") + secret_key: str = Field(..., alias="secretAccessKey") + session_token: str = Field(..., alias="sessionToken") region: str bucket: str images: Dict[int, dict] - class Config: - extra = Extra.allow - fields = { - "access_key": "accessKeyId", - "secret_key": "secretAccessKey", - "session_token": "sessionToken", - "region": "region", - } - def __init__(self, **data): credentials = data["creds"] data.update(credentials) @@ -58,40 +49,39 @@ def __init__(self, **data): class UploadAnnotations(BaseModel): - class Resource(BaseModel): - classes: List[str] = Field([], alias="class") - templates: List[str] = Field([], alias="template") - attributes: List[str] = Field([], alias="attribute") - attribute_groups: Optional[List[str]] = Field([], alias="attributeGroup") + model_config = ConfigDict(extra="ignore") - failed_items: List[str] = Field([], alias="failedItems") - missing_resources: Resource = Field({}, alias="missingResources") + class Resource(BaseModel): + classes: List[str] = Field(default_factory=list, alias="class") + templates: List[str] = Field(default_factory=list, alias="template") + attributes: List[str] = Field(default_factory=list, alias="attribute") + attribute_groups: Optional[List[str]] = Field( + default_factory=list, alias="attributeGroup" + ) - class Config: - extra = Extra.ignore + failed_items: List[str] = Field(default_factory=list, alias="failedItems") + missing_resources: Optional[Resource] = Field(None, alias="missingResources") class UploadCustomFieldValues(BaseModel): - succeeded_items: Optional[List[Any]] - failed_items: Optional[List[str]] - error: Optional[Any] + model_config = ConfigDict(extra="ignore") - class Config: - extra = Extra.ignore + succeeded_items: Optional[List[Any]] = None + failed_items: Optional[List[str]] = None + error: Optional[Any] = None class ServiceResponse(BaseModel): - status: Optional[int] - reason: Optional[str] + model_config = ConfigDict(extra="allow") + + status: Optional[int] = None + reason: Optional[str] = None content: Optional[Union[bytes, str]] = None res_data: Optional[Any] = None # response data res_error: Optional[Union[str, list, dict]] = None count: Optional[int] = 0 total: Optional[int] = 0 - class Config: - extra = Extra.allow - @property def total_count(self): if self.total: @@ -183,11 +173,11 @@ class ModelListResponse(ServiceResponse): class _IntegrationResponse(ServiceResponse): - integrations: List[entities.IntegrationEntity] = [] + integrations: List[entities.IntegrationEntity] = Field(default_factory=list) class IntegrationListResponse(ServiceResponse): - res_data: _IntegrationResponse + res_data: Optional[_IntegrationResponse] = None class AnnotationClassResponse(ServiceResponse): diff --git a/src/superannotate/lib/core/types.py b/src/superannotate/lib/core/types.py index 2464ea3aa..a43814153 100644 --- a/src/superannotate/lib/core/types.py +++ b/src/superannotate/lib/core/types.py @@ -1,17 +1,17 @@ from typing import Optional -from lib.core.pydantic_v1 import BaseModel -from lib.core.pydantic_v1 import constr -from lib.core.pydantic_v1 import Extra +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import StringConstraints +from typing_extensions import Annotated -NotEmptyStr = constr(strict=True, min_length=1) +NotEmptyStr = Annotated[str, StringConstraints(strict=True, min_length=1)] class Project(BaseModel): - name: NotEmptyStr + model_config = ConfigDict(extra="allow") - class Config: - extra = Extra.allow + name: NotEmptyStr class PriorityScoreEntity(BaseModel): diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index fde5dd005..a01320a0e 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -54,11 +54,8 @@ from lib.core.utils import run_async from lib.core.video_convertor import VideoFrameGenerator from lib.infrastructure.utils import divide_to_chunks - -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel +from pydantic import BaseModel +from pydantic import ConfigDict logger = logging.getLogger("sa") @@ -106,15 +103,14 @@ def log_report( class ItemToUpload(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + item: BaseItemEntity annotation_json: Optional[dict] = None path: Optional[str] = None file_size: Optional[int] = None mask: Optional[io.BytesIO] = None - class Config: - arbitrary_types_allowed = True - def set_annotation_statuses_in_progress( service_provider: BaseServiceProvider, @@ -1619,7 +1615,7 @@ def download_annotation_classes(self, path: str): with open(classes_path / "classes.json", "w+", encoding="utf-8") as file: json.dump( [ - i.dict( + i.model_dump( exclude_unset=True, by_alias=True, exclude={ @@ -1976,9 +1972,9 @@ def execute(self): ] = annotation valid_items_count += 1 if serialized_folder_name not in serialized_original_folder_map: - serialized_original_folder_map[ - serialized_folder_name - ] = folder_name + serialized_original_folder_map[serialized_folder_name] = ( + folder_name + ) else: failed.append(annotation) logger.info( diff --git a/src/superannotate/lib/core/usecases/classes.py b/src/superannotate/lib/core/usecases/classes.py index 117213156..ef16d380e 100644 --- a/src/superannotate/lib/core/usecases/classes.py +++ b/src/superannotate/lib/core/usecases/classes.py @@ -30,7 +30,7 @@ def execute(self): response = self._service_provider.annotation_classes.list(self._condition) if response.ok: classes = [ - entity.dict(by_alias=True, exclude_unset=True) + entity.model_dump(by_alias=True, exclude_unset=True) for entity in response.data ] self._response.data = classes @@ -181,7 +181,7 @@ def execute(self): ) if response.ok: classes = [ - entity.dict(by_alias=True, exclude_unset=True) + entity.model_dump(by_alias=True, exclude_unset=True) for entity in response.data ] json_path = f"{self._download_path}/classes.json" diff --git a/src/superannotate/lib/core/usecases/folders.py b/src/superannotate/lib/core/usecases/folders.py index 2d054f870..40fc9d671 100644 --- a/src/superannotate/lib/core/usecases/folders.py +++ b/src/superannotate/lib/core/usecases/folders.py @@ -63,9 +63,11 @@ def validate_folder(self): > 0 ): self._folder.name = "".join( - "_" - if char in constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES - else char + ( + "_" + if char in constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + else char + ) for char in self._folder.name ) logger.warning( @@ -180,9 +182,11 @@ def validate_folder(self): > 0 ): self._folder.name = "".join( - "_" - if char in constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES - else char + ( + "_" + if char in constances.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + else char + ) for char in self._folder.name ) logger.warning( diff --git a/src/superannotate/lib/core/usecases/images.py b/src/superannotate/lib/core/usecases/images.py index a9dd13adc..12e0399f1 100644 --- a/src/superannotate/lib/core/usecases/images.py +++ b/src/superannotate/lib/core/usecases/images.py @@ -669,7 +669,7 @@ def execute(self): project_type=constances.ProjectType(self._project.type).name, image_path=download_path, classes=[ - annotation_class.dict(exclude_unset=True) + annotation_class.model_dump(exclude_unset=True) for annotation_class in classes ], generate_overlay=self._include_overlay, @@ -800,9 +800,11 @@ def execute(self) -> Response: s3_upload_response = UploadImageS3UseCase( project=self._project, - image_path=self._image_name - if self._image_name - else Path(self._image_path).name, + image_path=( + self._image_name + if self._image_name + else Path(self._image_path).name + ), service_provider=self._service_provider, image=image_bytes, s3_repo=self.s3_repo, diff --git a/src/superannotate/lib/core/usecases/integrations.py b/src/superannotate/lib/core/usecases/integrations.py index 2d7da8dbd..0093f8ed0 100644 --- a/src/superannotate/lib/core/usecases/integrations.py +++ b/src/superannotate/lib/core/usecases/integrations.py @@ -164,9 +164,11 @@ def execute(self) -> Response: project=self._project, folder=self._folder, integration=self._integration, - folder_name=self._folder_path - if self._integration.type not in self.MULTIMODAL_INTEGRATIONS - else None, + folder_name=( + self._folder_path + if self._integration.type not in self.MULTIMODAL_INTEGRATIONS + else None + ), options=self._options if self._options else None, ) if not attache_response.ok: diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index fe9a64918..05feb84e1 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -40,7 +40,6 @@ from lib.infrastructure.utils import extract_project_folder from typing_extensions import Literal - logger = logging.getLogger("sa") @@ -48,7 +47,7 @@ def serialize_item_entity( entity: Union[BaseItemEntity, dict], project: ProjectEntity, map_fields: bool = True ) -> BaseItemEntity: if isinstance(entity, BaseItemEntity): - entity = entity.dict() + entity = entity.model_dump() if map_fields: entity = BaseItemEntity.map_fields(entity) entity = BaseItemEntity(**entity) @@ -61,13 +60,13 @@ def serialize_item_entity( if project.upload_state == constants.UploadState.EXTERNAL.value: tmp_entity.prediction_status = None tmp_entity.segmentation_status = None - return ImageEntity(**tmp_entity.dict(by_alias=True)) + return ImageEntity(**tmp_entity.model_dump(by_alias=True)) elif project.type == constants.ProjectType.VIDEO.value: - return VideoEntity(**entity.dict(by_alias=True)) + return VideoEntity(**entity.model_dump(by_alias=True)) elif project.type == constants.ProjectType.DOCUMENT.value: - return DocumentEntity(**entity.dict(by_alias=True)) + return DocumentEntity(**entity.model_dump(by_alias=True)) elif project.type == constants.ProjectType.MULTIMODAL.value: - return MultiModalItemEntity(**entity.dict(by_alias=True)) + return MultiModalItemEntity(**entity.model_dump(by_alias=True)) return entity diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index 32f6795bb..08123cf01 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -28,7 +28,6 @@ from lib.core.usecases.base import BaseUserBasedUseCase from pydantic import ValidationError - logger = logging.getLogger("sa") @@ -279,9 +278,11 @@ def validate_project_name(self): > 0 ): self._project.name = "".join( - "_" - if char in constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES - else char + ( + "_" + if char in constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + else char + ) for char in self._project.name ) logger.warning( @@ -328,10 +329,10 @@ def execute(self): if self._service_provider.annotation_classes: for annotation_class in self._project.classes: - annotation_classes_mapping[ - annotation_class.id - ] = self._service_provider.annotation_classes.create_multiple( - entity, [annotation_class] + annotation_classes_mapping[annotation_class.id] = ( + self._service_provider.annotation_classes.create_multiple( + entity, [annotation_class] + ) ) data["classes"] = self._project.classes logger.info( @@ -420,9 +421,11 @@ def validate_project_name(self): > 0 ): self._project.name = "".join( - "_" - if char in constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES - else char + ( + "_" + if char in constants.SPECIAL_CHARACTERS_IN_PROJECT_FOLDER_NAMES + else char + ) for char in self._project.name ) logger.warning( @@ -517,7 +520,7 @@ def execute(self): data = [] steps = self._service_provider.projects.list_steps(self._project).data for step in steps: - step_data = step.dict() + step_data = step.model_dump() annotation_classes = self._service_provider.annotation_classes.list( Condition("project_id", self._project.id, EQ) ).data @@ -778,7 +781,7 @@ def execute(self): if not response.ok: raise AppException(response.error) self._response.data = response.data - except Exception: + except Exception as e: raise AppException( "Unable to retrieve team data. Please verify your credentials." ) from None @@ -873,9 +876,11 @@ def execute(self): to_skip = [] to_add = [] project_users = self._service_provider.work_management.list_users( - Filter("email", user_to_retrieve, OperatorEnum.IN) - if user_to_retrieve - else EmptyQuery(), + ( + Filter("email", user_to_retrieve, OperatorEnum.IN) + if user_to_retrieve + else EmptyQuery() + ), include_custom_fields=True, parent_entity=CustomFieldEntityEnum.PROJECT, project_id=self._project.id, diff --git a/src/superannotate/lib/core/video_convertor.py b/src/superannotate/lib/core/video_convertor.py index cc99e9772..5f07c18a9 100644 --- a/src/superannotate/lib/core/video_convertor.py +++ b/src/superannotate/lib/core/video_convertor.py @@ -6,21 +6,17 @@ from typing import Optional from lib.core.enums import AnnotationTypes - -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel +from pydantic import BaseModel class Annotation(BaseModel): instanceId: int type: str - className: Optional[str] - classId: Optional[int] - x: Optional[Any] - y: Optional[Any] - points: Any + className: Optional[str] = None + classId: Optional[int] = None + x: Optional[Any] = None + y: Optional[Any] = None + points: Any = None attributes: Optional[List[Any]] = [] keyframe: bool = False @@ -263,4 +259,7 @@ def _process(self): def __iter__(self): for frame_no in range(1, int(self.frames_count) + 1): frame = self.get_frame(frame_no) - yield {**frame.dict(exclude_unset=True), **frame.dict(exclude_none=True)} + yield { + **frame.model_dump(exclude_unset=True), + **frame.model_dump(exclude_none=True), + } diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index a8a12c6f3..91321b745 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -68,7 +68,6 @@ from lib.infrastructure.utils import extract_project_folder from typing_extensions import Unpack - logger = logging.getLogger("sa") diff --git a/src/superannotate/lib/infrastructure/custom_entities.py b/src/superannotate/lib/infrastructure/custom_entities.py index 9de2a3ad6..eab9312d1 100644 --- a/src/superannotate/lib/infrastructure/custom_entities.py +++ b/src/superannotate/lib/infrastructure/custom_entities.py @@ -2,7 +2,6 @@ from lib.core.jsx_conditions import OperatorEnum from typing_extensions import Any - FIELD_TYPE_SUPPORTED_OPERATIONS_MAPPING = { CustomFieldType.Text: [ OperatorEnum.EQ, diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index 58f9c4d45..71a512183 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -345,9 +345,7 @@ def create_custom_workflow(self, org_id: str, data: dict): method="post", headers={ "x-sa-entity-context": base64.b64encode( - f'{{"team_id":{self.client.team_id},"organization_id":"{org_id}"}}'.encode( - "utf-8" - ) + f'{{"team_id":{self.client.team_id},"organization_id":"{org_id}"}}'.encode() ).decode() }, data=data, diff --git a/src/superannotate/lib/infrastructure/services/__init__.py b/src/superannotate/lib/infrastructure/services/__init__.py index e99ed6bc0..72bb58678 100644 --- a/src/superannotate/lib/infrastructure/services/__init__.py +++ b/src/superannotate/lib/infrastructure/services/__init__.py @@ -4,7 +4,6 @@ from .item import ItemService from .project import ProjectService - __all__ = [ "HttpClient", "ProjectService", diff --git a/src/superannotate/lib/infrastructure/services/annotation.py b/src/superannotate/lib/infrastructure/services/annotation.py index 74c1a791f..6ad2b74f1 100644 --- a/src/superannotate/lib/infrastructure/services/annotation.py +++ b/src/superannotate/lib/infrastructure/services/annotation.py @@ -17,16 +17,11 @@ from lib.core.service_types import UploadAnnotations from lib.core.service_types import UploadAnnotationsResponse from lib.core.serviceproviders import BaseAnnotationService +from lib.infrastructure.services.http_client import AIOHttpSession from lib.infrastructure.stream_data_handler import StreamedAnnotations from lib.infrastructure.utils import annotation_is_valid from lib.infrastructure.utils import divide_to_chunks - -try: - from pydantic.v1 import parse_obj_as -except ImportError: - from pydantic import parse_obj_as - -from lib.infrastructure.services.http_client import AIOHttpSession +from pydantic import TypeAdapter logger = logging.getLogger("sa") @@ -335,7 +330,9 @@ async def upload_small_annotations( response.status = _response.status response._content = await _response.text() # TODO add error handling - response.res_data = parse_obj_as(UploadAnnotations, data_json) + response.res_data = TypeAdapter(UploadAnnotations).validate_python( + data_json + ) return response async def upload_big_annotation( diff --git a/src/superannotate/lib/infrastructure/services/annotation_class.py b/src/superannotate/lib/infrastructure/services/annotation_class.py index e281649b4..33b3cad72 100644 --- a/src/superannotate/lib/infrastructure/services/annotation_class.py +++ b/src/superannotate/lib/infrastructure/services/annotation_class.py @@ -32,9 +32,11 @@ def create_multiple( def list(self, condition: Condition = None) -> ServiceResponse: return self.client.paginate( - url=f"{self.URL_LIST}?{condition.build_query()}" - if condition - else self.URL_LIST, + url=( + f"{self.URL_LIST}?{condition.build_query()}" + if condition + else self.URL_LIST + ), item_type=entities.AnnotationClassEntity, ) diff --git a/src/superannotate/lib/infrastructure/services/folder.py b/src/superannotate/lib/infrastructure/services/folder.py index eac6cf2da..9e450e8c7 100644 --- a/src/superannotate/lib/infrastructure/services/folder.py +++ b/src/superannotate/lib/infrastructure/services/folder.py @@ -46,7 +46,10 @@ def list(self, condition: Condition = None): def update(self, project: entities.ProjectEntity, folder: entities.FolderEntity): params = {"project_id": project.id} return self.client.request( - self.URL_UPDATE.format(folder.id), "put", data=folder.dict(), params=params + self.URL_UPDATE.format(folder.id), + "put", + data=folder.model_dump(), + params=params, ) def delete_multiple( diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index ee715d034..265b6c42c 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -9,6 +9,7 @@ import time import urllib.parse from contextlib import contextmanager +from enum import Enum from functools import lru_cache from typing import Any from typing import Dict @@ -24,23 +25,20 @@ from lib.core.jsx_conditions import Query from lib.core.service_types import ServiceResponse from lib.core.serviceproviders import BaseClient +from pydantic import BaseModel +from pydantic import TypeAdapter from requests.adapters import HTTPAdapter from requests.adapters import Retry -try: - from pydantic.v1 import BaseModel - from pydantic.v1 import parse_obj_as -except ImportError: - from pydantic import BaseModel - from pydantic import parse_obj_as - logger = logging.getLogger("sa") class PydanticEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, BaseModel): - return json.loads(obj.json(exclude_none=True)) + return obj.model_dump(exclude_none=True, mode="json") + if isinstance(obj, Enum): + return obj.value return json.JSONEncoder.default(self, obj) @@ -200,7 +198,7 @@ def paginate( if item_type: response = ServiceResponse( status=_response.status, - res_data=parse_obj_as(List[item_type], total), + res_data=TypeAdapter(List[item_type]).validate_python(total), ) else: response = ServiceResponse( @@ -252,7 +250,7 @@ def jsx_paginate( if item_type: response = ServiceResponse( status=_response.status, - res_data=parse_obj_as(List[item_type], total), + res_data=TypeAdapter(List[item_type]).validate_python(total), ) else: response = ServiceResponse( @@ -274,9 +272,9 @@ def serialize_response( try: if not response.ok: if response.status_code in (502, 504): - data[ - "res_error" - ] = "Our service is currently unavailable, please try again later." + data["res_error"] = ( + "Our service is currently unavailable, please try again later." + ) return content_type(**data) else: data_json = response.json() diff --git a/src/superannotate/lib/infrastructure/services/item.py b/src/superannotate/lib/infrastructure/services/item.py index 828802cbf..fcb1e454b 100644 --- a/src/superannotate/lib/infrastructure/services/item.py +++ b/src/superannotate/lib/infrastructure/services/item.py @@ -27,7 +27,7 @@ def update(self, project: entities.ProjectEntity, item: entities.BaseItemEntity) return self.client.request( self.URL_GET.format(item.id), "put", - data=item.dict(), + data=item.model_dump(), params={"project_id": project.id}, ) @@ -44,7 +44,7 @@ def attach( "project_id": project.id, "folder_id": folder.id, "team_id": project.team_id, - "images": [i.dict() for i in attachments], + "images": [i.model_dump() for i in attachments], "upload_state": upload_state_code, "meta": {}, } diff --git a/src/superannotate/lib/infrastructure/services/item_service.py b/src/superannotate/lib/infrastructure/services/item_service.py index 1e3416009..2c5b647a0 100644 --- a/src/superannotate/lib/infrastructure/services/item_service.py +++ b/src/superannotate/lib/infrastructure/services/item_service.py @@ -22,7 +22,7 @@ def get(self, project_id: int, item_id: int, query: Query): headers={ "x-sa-entity-context": base64.b64encode( f'{{"team_id":{self.client.team_id},' - f'"project_id":{project_id}}}'.encode("utf-8") + f'"project_id":{project_id}}}'.encode() ).decode() }, ) @@ -45,7 +45,7 @@ def list(self, project_id: int, folder_id: Optional[int], query: Query): item_type=BaseItemEntity, headers={ "x-sa-entity-context": base64.b64encode( - f"{{{','.join(entity_context)}}}".encode("utf-8") + f"{{{','.join(entity_context)}}}".encode() ).decode() }, ) diff --git a/src/superannotate/lib/infrastructure/services/work_management.py b/src/superannotate/lib/infrastructure/services/work_management.py index 6532dc505..7e7ea7ec4 100644 --- a/src/superannotate/lib/infrastructure/services/work_management.py +++ b/src/superannotate/lib/infrastructure/services/work_management.py @@ -374,9 +374,9 @@ def create_custom_field_template( data={ "name": name, "component_id": component_id, - "component_payload": component_payload - if component_payload is not None - else {}, + "component_payload": ( + component_payload if component_payload is not None else {} + ), "access": access if access is not None else {}, }, headers={ @@ -550,7 +550,9 @@ def update_annotation_class( return self.client.request( url=self.URL_UPDATE_ANNOTATION_CLASS.format(class_id=class_id), method="patch", - data=data.dict(exclude_unset=True, by_alias=True), + data=data.model_dump( + exclude_unset=True, by_alias=True, mode="json", exclude_none=True + ), headers={ "x-sa-entity-context": self._generate_context( team_id=self.client.team_id, project_id=project_id diff --git a/src/superannotate/lib/infrastructure/utils.py b/src/superannotate/lib/infrastructure/utils.py index 8e5043c3b..48ea1e81f 100644 --- a/src/superannotate/lib/infrastructure/utils.py +++ b/src/superannotate/lib/infrastructure/utils.py @@ -21,7 +21,6 @@ from lib.core.exceptions import PathError from lib.infrastructure.services.work_management import WorkManagementService - logger = logging.getLogger("sa") diff --git a/src/superannotate/lib/infrastructure/validators.py b/src/superannotate/lib/infrastructure/validators.py index 44cfbea9a..17913a9dd 100644 --- a/src/superannotate/lib/infrastructure/validators.py +++ b/src/superannotate/lib/infrastructure/validators.py @@ -1,18 +1,41 @@ import os import typing from collections import defaultdict +from typing import get_args +from typing import get_origin -from lib.core.pydantic_v1 import ValidationError -from lib.core.pydantic_v1 import validators -from lib.core.pydantic_v1 import WrongConstantError +from pydantic import ValidationError -def wrong_constant_error(self): - permitted = ", ".join(repr(v) for v in self.permitted) # type: ignore - return f"Available values are {permitted}." +class WrongConstantError(ValueError): + """Custom error for wrong constant values.""" + def __init__(self, given: typing.Any, permitted: typing.Tuple[typing.Any, ...]): + self.given = given + self.permitted = permitted + super().__init__(self._message()) -WrongConstantError.__str__ = wrong_constant_error + def _message(self) -> str: + permitted = ", ".join(repr(v) for v in self.permitted) + return f"Available values are {permitted}." + + def __str__(self) -> str: + return self._message() + + +def all_literal_values(type_: typing.Any) -> typing.Tuple[typing.Any, ...]: + """Extract all literal values from a Literal type.""" + origin = get_origin(type_) + if origin is typing.Literal: + return get_args(type_) + # Handle Union of Literals + if origin is typing.Union: + values = [] + for arg in get_args(type_): + if get_origin(arg) is typing.Literal: + values.extend(get_args(arg)) + return tuple(values) + return () def make_literal_validator( @@ -21,48 +44,20 @@ def make_literal_validator( """ Adding ability to input literal in the lower case. """ - permitted_choices = validators.all_literal_values(type_) - allowed_choices = {v.lower() if v else v: v for v in permitted_choices} + permitted_choices = all_literal_values(type_) + allowed_choices = { + v.lower() if isinstance(v, str) and v else v: v for v in permitted_choices + } def literal_validator(v: typing.Any) -> typing.Any: try: - return allowed_choices[v.lower()] + return allowed_choices[v.lower() if isinstance(v, str) else v] except (KeyError, AttributeError): raise WrongConstantError(given=v, permitted=permitted_choices) return literal_validator -def make_typeddict_validator( - typeddict_cls: typing.Type["TypedDict"], config: typing.Type["BaseConfig"] # type: ignore[valid-type] -) -> typing.Callable[[typing.Any], typing.Dict[str, typing.Any]]: - """ - Wrapping to ignore extra keys - """ - from lib.core.pydantic_v1 import Extra - from lib.core.pydantic_v1 import create_model_from_typeddict - - create_model_from_typeddict = create_model_from_typeddict - - config.extra = Extra.ignore - - TypedDictModel = create_model_from_typeddict( # noqa - typeddict_cls, - __config__=config, - __module__=typeddict_cls.__module__, - ) - typeddict_cls.__pydantic_model__ = TypedDictModel # type: ignore[attr-defined] - - def typeddict_validator(values: "TypedDict") -> typing.Dict[str, typing.Any]: # type: ignore[valid-type] - return TypedDictModel.parse_obj(values).dict(exclude_unset=True) - - return typeddict_validator - - -validators.make_literal_validator = make_literal_validator -validators.make_typeddict_validator = make_typeddict_validator - - def get_tabulation() -> int: try: return int(os.get_terminal_size().columns / 2) @@ -70,11 +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"]) + 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/aggregations/test_df_processing.py b/tests/integration/aggregations/test_df_processing.py index fad758fbc..321b1379a 100644 --- a/tests/integration/aggregations/test_df_processing.py +++ b/tests/integration/aggregations/test_df_processing.py @@ -29,7 +29,7 @@ def test_filter_instances(self): ) def test_invalid_project_type(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "The function is not supported for PointCloud projects." ): sa.aggregate_annotations_as_df(self.folder_path, "PointCloud") diff --git a/tests/integration/annotations/test_annotation_delete.py b/tests/integration/annotations/test_annotation_delete.py index ef32d4534..387b0e217 100644 --- a/tests/integration/annotations/test_annotation_delete.py +++ b/tests/integration/annotations/test_annotation_delete.py @@ -95,7 +95,7 @@ def test_delete_annotations_by_not_existing_name(self): sa.upload_annotations_from_folder_to_project( self.PROJECT_NAME, f"{self.folder_path}" ) - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Invalid item names or empty folder." ): sa.delete_annotations(self.PROJECT_NAME, [self.EXAMPLE_IMAGE_2]) diff --git a/tests/integration/annotations/test_annotation_upload_vector.py b/tests/integration/annotations/test_annotation_upload_vector.py index cb2e3b08b..727d3a232 100644 --- a/tests/integration/annotations/test_annotation_upload_vector.py +++ b/tests/integration/annotations/test_annotation_upload_vector.py @@ -10,7 +10,6 @@ from src.superannotate import SAClient from tests.integration.base import BaseTestCase - sa = SAClient() diff --git a/tests/integration/annotations/test_download_annotations.py b/tests/integration/annotations/test_download_annotations.py index 505b3bbb7..d0f3d4412 100644 --- a/tests/integration/annotations/test_download_annotations.py +++ b/tests/integration/annotations/test_download_annotations.py @@ -8,7 +8,6 @@ from src.superannotate import SAClient from tests.integration.base import BaseTestCase - sa = SAClient() diff --git a/tests/integration/annotations/test_large_annotations.py b/tests/integration/annotations/test_large_annotations.py index be33f27dc..549713919 100644 --- a/tests/integration/annotations/test_large_annotations.py +++ b/tests/integration/annotations/test_large_annotations.py @@ -9,7 +9,6 @@ from tests import DATA_SET_PATH from tests.integration.base import BaseTestCase - logging.basicConfig(level=logging.DEBUG) sa = SAClient() diff --git a/tests/integration/annotations/test_upload_annotations.py b/tests/integration/annotations/test_upload_annotations.py index 374813bd7..a86e62e8a 100644 --- a/tests/integration/annotations/test_upload_annotations.py +++ b/tests/integration/annotations/test_upload_annotations.py @@ -222,7 +222,7 @@ def test_error_upload_from_folder_to_folder_(self): with open(self.JSONL_ANNOTATIONS_PATH) as f: data = [json.loads(line) for line in f] sa.create_folder(self.PROJECT_NAME, "tmp") - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "You can't include a folder when uploading from within a folder.", ): @@ -252,7 +252,7 @@ def test_upload_with_integer_names(self): f"{self.PROJECT_NAME}", annotations=data, data_spec="multimodal" ) assert len(res["failed"]) == 3 - with self.assertRaisesRegexp(AppException, "Folder not found."): + with self.assertRaisesRegex(AppException, "Folder not found."): sa.get_annotations( f"{self.PROJECT_NAME}/test_folder", data_spec="multimodal" ) diff --git a/tests/integration/base.py b/tests/integration/base.py index 30874716a..2b7a8344c 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -2,7 +2,6 @@ from src.superannotate import SAClient - sa = SAClient() @@ -24,7 +23,7 @@ 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) diff --git a/tests/integration/classes/test_create_annotation_classes_from_classes_json.py b/tests/integration/classes/test_create_annotation_classes_from_classes_json.py index d311b6b6d..e918bdd7c 100644 --- a/tests/integration/classes/test_create_annotation_classes_from_classes_json.py +++ b/tests/integration/classes/test_create_annotation_classes_from_classes_json.py @@ -80,8 +80,7 @@ def test_create_annotation_class(self): with tempfile.TemporaryDirectory() as tmpdir_name: temp_path = f"{tmpdir_name}/new_classes.json" with open(temp_path, "w") as new_classes: - new_classes.write( - """ + new_classes.write(""" [ { "id":56820, @@ -105,8 +104,7 @@ def test_create_annotation_class(self): } ] - """ - ) + """) msg = "" try: sa.create_annotation_classes_from_classes_json( @@ -123,8 +121,7 @@ def test_create_annotation_class_via_json_and_ocr_group_type(self): with tempfile.TemporaryDirectory() as tmpdir_name: temp_path = f"{tmpdir_name}/new_classes.json" with open(temp_path, "w") as new_classes: - new_classes.write( - """ + new_classes.write(""" [ { "id":56820, @@ -148,9 +145,8 @@ def test_create_annotation_class_via_json_and_ocr_group_type(self): ] } ] - """ - ) - with self.assertRaisesRegexp( + """) + with self.assertRaisesRegex( AppException, f"OCR attribute group is not supported for project type {self.PROJECT_TYPE}.", ): diff --git a/tests/integration/classes/test_create_update_annotation_class.py b/tests/integration/classes/test_create_update_annotation_class.py index 3c27431a1..8e2e839e0 100644 --- a/tests/integration/classes/test_create_update_annotation_class.py +++ b/tests/integration/classes/test_create_update_annotation_class.py @@ -167,8 +167,7 @@ def test_class_creation_type(self): with tempfile.TemporaryDirectory() as tmpdir_name: temp_path = f"{tmpdir_name}/new_classes.json" with open(temp_path, "w") as new_classes: - new_classes.write( - """ + new_classes.write(""" [ { "id":56820, @@ -276,8 +275,7 @@ def test_class_creation_type(self): } ] - """ - ) + """) created = sa.create_annotation_classes_from_classes_json( self.PROJECT_NAME, temp_path @@ -618,7 +616,10 @@ def test_update_annotation_class_invalid_group_type(self): ) classes[0]["attribute_groups"][0]["group_type"] = "invalid" - with self.assertRaisesRegexp(AppException, "Invalid group_type: invalid"): + with self.assertRaisesRegex( + AppException, + "Input should be 'radio', 'checklist', 'numeric', 'text' or 'ocr'", + ): sa.update_annotation_class( self.PROJECT_NAME, "test_update_nochange", @@ -646,7 +647,10 @@ def test_update_annotation_class_without_group_type(self): ) del classes[0]["attribute_groups"][0]["group_type"] - with self.assertRaisesRegexp(AppException, "Invalid group_type: invalid"): + with self.assertRaisesRegex( + AppException, + "Input should be 'radio', 'checklist', 'numeric', 'text' or 'ocr'", + ): res = sa.update_annotation_class( self.PROJECT_NAME, "test_update_nochange", @@ -654,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" @@ -665,8 +677,7 @@ def test_create_annotation_class(self): with tempfile.TemporaryDirectory() as tmpdir_name: temp_path = f"{tmpdir_name}/new_classes.json" with open(temp_path, "w") as new_classes: - new_classes.write( - """ + new_classes.write(""" [ { "id":56820, @@ -689,8 +700,7 @@ def test_create_annotation_class(self): } ] - """ - ) + """) msg = "" try: sa.create_annotation_classes_from_classes_json( @@ -704,7 +714,7 @@ def test_create_annotation_class(self): ) def test_create_annotation_class_via_ocr_group_type(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, f"OCR attribute group is not supported for project type {self.PROJECT_TYPE}.", ): diff --git a/tests/integration/convertors/__init__.py b/tests/integration/convertors/__init__.py index 36753e8eb..05e0c6cd3 100644 --- a/tests/integration/convertors/__init__.py +++ b/tests/integration/convertors/__init__.py @@ -1,6 +1,5 @@ from pathlib import Path - DATA_SET_PATH = Path(__file__).parent / "data_set" diff --git a/tests/integration/custom_fields/test_custom_schema.py b/tests/integration/custom_fields/test_custom_schema.py index 8bb93a06c..539b0b208 100644 --- a/tests/integration/custom_fields/test_custom_schema.py +++ b/tests/integration/custom_fields/test_custom_schema.py @@ -42,7 +42,7 @@ def test_create_schema(self): def test_create_limit_25(self): payload = {i: {"type": "number"} for i in range(26)} - with self.assertRaisesRegexp( + with self.assertRaisesRegex( Exception, "Maximum number of custom fields is 25. You can only create 25 more custom fields.", ): @@ -50,7 +50,7 @@ def test_create_limit_25(self): def test_create_duplicated(self): payload = {"1": {"type": "number"}} - with self.assertRaisesRegexp(Exception, "Field name 1 is already used."): + with self.assertRaisesRegex(Exception, "Field name 1 is already used."): for i in range(2): sa.create_custom_fields(self.PROJECT_NAME, payload) @@ -190,5 +190,5 @@ def test_create_invalid(self): "-Minimum spec value of age_range can not be higher than maximum value.\n" "-Spec value type of age_enum is not valid." ) - with self.assertRaisesRegexp(AppException, error_msg): + with self.assertRaisesRegex(AppException, error_msg): sa.create_custom_fields(self.PROJECT_NAME, INVALID_SCHEMA) diff --git a/tests/integration/export/__init__.py b/tests/integration/export/__init__.py index d65e60375..fe0b71c8b 100644 --- a/tests/integration/export/__init__.py +++ b/tests/integration/export/__init__.py @@ -1,6 +1,5 @@ import pathlib - DATA_SET_PATH = pathlib.Path(__file__).parent / "data_set" diff --git a/tests/integration/export/test_export.py b/tests/integration/export/test_export.py index 1b4461713..5b0d8cd47 100644 --- a/tests/integration/export/test_export.py +++ b/tests/integration/export/test_export.py @@ -255,7 +255,7 @@ def test_delete_exports_empty_list(self): def test_delete_exports_project_not_found(self): """Test deleting exports from non-existent project""" - with self.assertRaisesRegexp(AppException, "Project not found"): + with self.assertRaisesRegex(AppException, "Project not found"): sa.delete_exports("NonExistentProject123456", exports=["*"]) def test_delete_mixed_valid_invalid_exports(self): @@ -271,7 +271,7 @@ def test_delete_mixed_valid_invalid_exports(self): with self.assertLogs("sa", level="INFO") as cm: sa.delete_exports( self.PROJECT_NAME, - exports=[export1["name"], "invalid_name", 99999, export2["id"]], + exports=[export1["name"], "invalid_name", export2["name"]], ) assert "Successfully removed 2 export(s)." in cm.output[0] diff --git a/tests/integration/folders/test_create_folder.py b/tests/integration/folders/test_create_folder.py index bc7cd1b7f..68952c79b 100644 --- a/tests/integration/folders/test_create_folder.py +++ b/tests/integration/folders/test_create_folder.py @@ -14,7 +14,7 @@ class TestCreateFolder(BaseTestCase): def test_create_long_name(self): err_msg = "The folder name is too long. The maximum length for this field is 80 characters." - with self.assertRaisesRegexp(AppException, err_msg): + with self.assertRaisesRegex(AppException, err_msg): sa.create_folder( self.PROJECT_NAME, "A while back I needed to count the amount of letters that " diff --git a/tests/integration/folders/test_delete_folders.py b/tests/integration/folders/test_delete_folders.py index ab76b01cd..edd85d131 100644 --- a/tests/integration/folders/test_delete_folders.py +++ b/tests/integration/folders/test_delete_folders.py @@ -2,7 +2,6 @@ from src.superannotate import SAClient from tests.integration.base import BaseTestCase - sa = SAClient() @@ -32,11 +31,11 @@ def test_search_folders(self): for folder_name in folder_names ] - with self.assertRaisesRegexp(AppException, "There is no folder to delete."): + with self.assertRaisesRegex(AppException, "There is no folder to delete."): sa.delete_folders(self.PROJECT_NAME, []) - pattern = r"(\s+)folder_names(\s+)none is not an allowed value" + pattern = r"(\s+)argument at index 2(\s+)Input should be a valid list" - with self.assertRaisesRegexp(AppException, pattern): + with self.assertRaisesRegex(AppException, pattern): sa.delete_folders(self.PROJECT_NAME, None) # noqa sa.delete_folders(self.PROJECT_NAME, folder_names) diff --git a/tests/integration/folders/test_search_folders.py b/tests/integration/folders/test_search_folders.py index ab9fa63c5..c368d8775 100644 --- a/tests/integration/folders/test_search_folders.py +++ b/tests/integration/folders/test_search_folders.py @@ -52,8 +52,8 @@ def test_search_folders(self): # with invalid status pattern = ( - r"(\s+)status(\s+)Available values are 'NotStarted', " - r"'InProgress', 'Completed', 'OnHold'.(\s+)value is not a valid list" + r"(\s+)status(\s+)Input should be 'NotStarted', 'InProgress', " + r"'Completed' or 'OnHold'(\s+)Input should be a valid list" ) - with self.assertRaisesRegexp(AppException, pattern): + with self.assertRaisesRegex(AppException, pattern): folders = sa.search_folders(self.PROJECT_NAME, status="dummy") # noqa diff --git a/tests/integration/folders/test_set_folder_status.py b/tests/integration/folders/test_set_folder_status.py index 267983a0b..5c1b78fc5 100644 --- a/tests/integration/folders/test_set_folder_status.py +++ b/tests/integration/folders/test_set_folder_status.py @@ -5,7 +5,6 @@ from src.superannotate.lib.core.service_types import ServiceResponse from superannotate import SAClient - sa = SAClient() @@ -51,7 +50,7 @@ def test_set_folder_status(self): @patch("lib.infrastructure.services.folder.FolderService.update") def test_set_folder_status_fail(self, update_function): update_function.return_value = ServiceResponse(_error="ERROR") - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, f"Failed to change {self.PROJECT_NAME}/{self.FOLDER_NAME} status.", ): @@ -60,9 +59,9 @@ def test_set_folder_status_fail(self, update_function): ) def test_set_folder_status_via_invalid_status(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, - "Available values are 'NotStarted', 'InProgress', 'Completed', 'OnHold'.", + "Input should be 'NotStarted', 'InProgress', 'Completed' or 'OnHold'", ): sa.set_folder_status( project=self.PROJECT_NAME, @@ -71,7 +70,7 @@ def test_set_folder_status_via_invalid_status(self): ) def test_set_folder_status_via_invalid_project(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Project not found.", ): @@ -80,7 +79,7 @@ def test_set_folder_status_via_invalid_project(self): ) def test_set_folder_status_via_invalid_folder(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Folder not found.", ): diff --git a/tests/integration/items/test_attach_items.py b/tests/integration/items/test_attach_items.py index 60956aee1..3847c6449 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) - with self.assertRaisesRegexp(AppException, pattern): - sa.attach_items(self.PROJECT_NAME, [{"name": "name"}]) + pattern = r"[\s\S]+" + r"[\s\S]+".join(error_msg) + with self.assertRaisesRegex(AppException, pattern): + sa.attach_items(self.PROJECT_NAME, attachments=[{"name": "name"}]) diff --git a/tests/integration/items/test_copy_items.py b/tests/integration/items/test_copy_items.py index a8eee95a5..eaff63819 100644 --- a/tests/integration/items/test_copy_items.py +++ b/tests/integration/items/test_copy_items.py @@ -74,11 +74,11 @@ def test_copy_items_from_root_with_annotations(self): ) def test_copy_items_from_not_existing_folder(self): - with self.assertRaisesRegexp(AppException, "Folder not found."): + with self.assertRaisesRegex(AppException, "Folder not found."): sa.copy_items(f"{self.PROJECT_NAME}/{self.FOLDER_1}", self.PROJECT_NAME) def test_copy_items_to_not_existing_folder(self): - with self.assertRaisesRegexp(AppException, "Folder not found."): + with self.assertRaisesRegex(AppException, "Folder not found."): sa.copy_items(self.PROJECT_NAME, f"{self.PROJECT_NAME}/{self.FOLDER_1}") def test_copy_items_from_folder(self): diff --git a/tests/integration/items/test_generate_items.py b/tests/integration/items/test_generate_items.py index 50e2c7eaa..9f895bab2 100644 --- a/tests/integration/items/test_generate_items.py +++ b/tests/integration/items/test_generate_items.py @@ -72,20 +72,20 @@ def test_generate_items_in_folder(self): assert actual_names == expected_names def test_invalid_name(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Invalid item name.", ): sa.generate_items(self.PROJECT_NAME, 100, name="a" * 115) - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Invalid item name.", ): sa.generate_items(self.PROJECT_NAME, 100, name="m<:") def test_item_count(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "The number of items you want to attach exceeds the limit of 50 000 items per folder.", ): diff --git a/tests/integration/items/test_item_context.py b/tests/integration/items/test_item_context.py index 1877e0684..454e48797 100644 --- a/tests/integration/items/test_item_context.py +++ b/tests/integration/items/test_item_context.py @@ -67,7 +67,7 @@ def _base_test(self, path, item): with sa.item_context(path, item, overwrite=False) as ic: assert ic.get_component_value("component_id_1") is None ic.set_component_value("component_id_1", "value") - with self.assertRaisesRegexp( + with self.assertRaisesRegex( FileChangedError, "The file has changed and overwrite is set to False." ): with sa.item_context(path, item, overwrite=False) as ic: diff --git a/tests/integration/items/test_list_items.py b/tests/integration/items/test_list_items.py index b2094e867..f75524475 100644 --- a/tests/integration/items/test_list_items.py +++ b/tests/integration/items/test_list_items.py @@ -41,15 +41,11 @@ def test_list_items(self): assert len(items) == 100 def test_invalid_filter(self): - with self.assertRaisesRegexp( - AppException, "Invalid assignments role provided." - ): + with self.assertRaisesRegex(AppException, "Invalid assignments role provided."): sa.list_items(self.PROJECT_NAME, assignments__user_role__in=["Approved"]) - with self.assertRaisesRegexp( - AppException, "Invalid assignments role provided." - ): + with self.assertRaisesRegex(AppException, "Invalid assignments role provided."): sa.list_items(self.PROJECT_NAME, assignments__user_role="Dummy") - with self.assertRaisesRegexp(AppException, "Invalid status provided."): + with self.assertRaisesRegex(AppException, "Invalid status provided."): sa.list_items(self.PROJECT_NAME, annotation_status="Dummy") def test_list_items_URL_limit(self): diff --git a/tests/integration/items/test_set_annotation_statuses.py b/tests/integration/items/test_set_annotation_statuses.py index 49da31035..01dddb12c 100644 --- a/tests/integration/items/test_set_annotation_statuses.py +++ b/tests/integration/items/test_set_annotation_statuses.py @@ -66,7 +66,7 @@ def test_image_annotation_status_via_names(self): def test_image_annotation_status_via_invalid_names(self): sa.attach_items(self.PROJECT_NAME, self.ATTACHMENT_LIST, "InProgress") - with self.assertRaisesRegexp(AppException, SetAnnotationStatues.ERROR_MESSAGE): + with self.assertRaisesRegex(AppException, SetAnnotationStatues.ERROR_MESSAGE): sa.set_annotation_statuses( self.PROJECT_NAME, "QualityCheck", diff --git a/tests/integration/items/test_set_approval_statuses.py b/tests/integration/items/test_set_approval_statuses.py index ba4800d17..153241465 100644 --- a/tests/integration/items/test_set_approval_statuses.py +++ b/tests/integration/items/test_set_approval_statuses.py @@ -61,7 +61,7 @@ def test_image_approval_status_via_names(self): def test_image_approval_status_via_invalid_names(self): sa.attach_items(self.PROJECT_NAME, ATTACHMENT_LIST, "InProgress") - with self.assertRaisesRegexp(AppException, "No items found."): + with self.assertRaisesRegex(AppException, "No items found."): sa.set_approval_statuses( self.PROJECT_NAME, "Approved", @@ -80,8 +80,8 @@ def test_set_approval_statuses(self): def test_set_invalid_approval_statuses(self): sa.attach_items(self.PROJECT_NAME, [ATTACHMENT_LIST[0]]) - with self.assertRaisesRegexp( - AppException, "Available values are 'Approved', 'Disapproved'." + with self.assertRaisesRegex( + AppException, "Input should be 'Approved', 'Disapproved' or None" ): sa.set_approval_statuses( self.PROJECT_NAME, diff --git a/tests/integration/mixpanel/test_mixpanel_decorator.py b/tests/integration/mixpanel/test_mixpanel_decorator.py index 9b26f0776..52f96614f 100644 --- a/tests/integration/mixpanel/test_mixpanel_decorator.py +++ b/tests/integration/mixpanel/test_mixpanel_decorator.py @@ -11,7 +11,6 @@ from src.superannotate import AppException from src.superannotate import SAClient - sa = SAClient() diff --git a/tests/integration/projects/test_basic_project.py b/tests/integration/projects/test_basic_project.py index 4bb519a05..6d8b777fd 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/projects/test_create_project.py b/tests/integration/projects/test_create_project.py index c89b0935d..40af65175 100644 --- a/tests/integration/projects/test_create_project.py +++ b/tests/integration/projects/test_create_project.py @@ -4,7 +4,6 @@ from src.superannotate import AppException from src.superannotate import SAClient - sa = SAClient() @@ -86,12 +85,12 @@ 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.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, - "Available values are '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 358b89eda..77b1260ec 100644 --- a/tests/integration/projects/test_set_project_status.py +++ b/tests/integration/projects/test_set_project_status.py @@ -5,7 +5,6 @@ from src.superannotate.lib.core.service_types import ServiceResponse from superannotate import SAClient - sa = SAClient() @@ -43,21 +42,21 @@ def test_set_project_status(self): @patch("lib.infrastructure.services.project.ProjectService.update") def test_set_project_status_fail(self, update_function): update_function.return_value = ServiceResponse(_error="ERROR") - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, f"Failed to change {self.PROJECT_NAME} status.", ): sa.set_project_status(project=self.PROJECT_NAME, status="Completed") def test_set_project_status_via_invalid_status(self): - with self.assertRaisesRegexp( + 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") def test_set_project_status_via_invalid_project(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Project not found.", ): diff --git a/tests/integration/settings/test_settings.py b/tests/integration/settings/test_settings.py index cf2fb85d7..7aaa1d4f3 100644 --- a/tests/integration/settings/test_settings.py +++ b/tests/integration/settings/test_settings.py @@ -63,7 +63,7 @@ def test_create_project_with_settings(self): raise Exception("Test failed") def test_frame_rate_invalid_range_value(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "FrameRate is available only for Video projects" ): sa.create_project( @@ -116,7 +116,7 @@ def test_frame_rate_float(self): raise Exception("Test failed") def test_frame_rate_invalid_range_value(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "The FrameRate value range is between 0.001 - 120" ): sa.create_project( @@ -127,7 +127,7 @@ def test_frame_rate_invalid_range_value(self): ) def test_frame_rate_invalid_str_value(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "The FrameRate value should be float" ): sa.create_project( diff --git a/tests/integration/steps/test_steps.py b/tests/integration/steps/test_steps.py index 6dbddf6dc..8b4160429 100644 --- a/tests/integration/steps/test_steps.py +++ b/tests/integration/steps/test_steps.py @@ -77,7 +77,7 @@ def test_create_steps(self): assert len(steps) == 2 def test_missing_ids(self): - with self.assertRaisesRegexp(AppException, "Annotation class not found."): + with self.assertRaisesRegex(AppException, "Annotation class not found."): sa.set_project_steps( self.PROJECT_NAME, steps=[ @@ -115,7 +115,7 @@ def test_missing_ids(self): connections=[[1, 2]], ) - with self.assertRaisesRegexp(AppException, "Invalid steps provided."): + with self.assertRaisesRegex(AppException, "Invalid steps provided."): sa.set_project_steps( self.PROJECT_NAME, steps=[ @@ -151,7 +151,7 @@ def test_missing_ids(self): connections=[[1, 2]], ) - with self.assertRaisesRegexp(AppException, "Invalid steps provided."): + with self.assertRaisesRegex(AppException, "Invalid steps provided."): sa.set_project_steps( self.PROJECT_NAME, steps=[ @@ -223,7 +223,7 @@ def test_create_invalid_connection(self): }, ], ) - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Invalid connections: duplicates in a connection group." ): sa.set_project_steps( @@ -233,7 +233,7 @@ def test_create_invalid_connection(self): [2, 1], ] ) - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Invalid connections: index out of allowed range." ): sa.set_project_steps(*args, connections=[[1, 3]]) diff --git a/tests/integration/test_basic_images.py b/tests/integration/test_basic_images.py index bda57c896..bd383ca09 100644 --- a/tests/integration/test_basic_images.py +++ b/tests/integration/test_basic_images.py @@ -55,7 +55,7 @@ class TestPinImage(BaseTestCase): PROJECT_DESCRIPTION = "TestPinImage" def test_pin_image_negative_name(self): - with self.assertRaisesRegexp(AppException, "Item not found."): + with self.assertRaisesRegex(AppException, "Item not found."): sa.pin_image(self.PROJECT_NAME, "NEW NAME") diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index bb4bf2405..f95fd8e72 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -11,7 +11,6 @@ from src.superannotate import SAClient from src.superannotate.lib.app.interface.cli_interface import CLIFacade - try: CLI_VERSION = pkg_resources.get_distribution("superannotate").version except Exception: @@ -176,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" diff --git a/tests/integration/test_depricated_functions_document.py b/tests/integration/test_depricated_functions_document.py index 64beac740..f61c603ef 100644 --- a/tests/integration/test_depricated_functions_document.py +++ b/tests/integration/test_depricated_functions_document.py @@ -10,7 +10,6 @@ from src.superannotate.lib.core import LIMITED_FUNCTIONS from src.superannotate.lib.core import ProjectType - sa = SAClient() diff --git a/tests/integration/test_image_upload.py b/tests/integration/test_image_upload.py index 8332db782..86f93453b 100644 --- a/tests/integration/test_image_upload.py +++ b/tests/integration/test_image_upload.py @@ -61,7 +61,11 @@ def classes_path(self): ) def test_multiple_image_upload(self): - (uploaded, could_not_upload, existing_images,) = sa.upload_images_to_project( + ( + uploaded, + could_not_upload, + existing_images, + ) = sa.upload_images_to_project( self.PROJECT_NAME, [ f"{self.folder_path}/example_image_1.jpg", @@ -74,7 +78,11 @@ def test_multiple_image_upload(self): self.assertEqual(len(could_not_upload), 0) self.assertEqual(len(existing_images), 0) - (uploaded, could_not_upload, existing_images,) = sa.upload_images_to_project( + ( + uploaded, + could_not_upload, + existing_images, + ) = sa.upload_images_to_project( self.PROJECT_NAME, [ f"{self.folder_path}/example_image_1.jpg", diff --git a/tests/integration/test_limitations.py b/tests/integration/test_limitations.py index 22e167da0..a0905e4c3 100644 --- a/tests/integration/test_limitations.py +++ b/tests/integration/test_limitations.py @@ -32,7 +32,7 @@ def folder_path(self): return_value=folder_limit_response, ) def test_folder_limitations(self, *_): - with self.assertRaisesRegexp(AppException, UPLOAD_FOLDER_LIMIT_ERROR_MESSAGE): + with self.assertRaisesRegex(AppException, UPLOAD_FOLDER_LIMIT_ERROR_MESSAGE): _, _, __ = sa.upload_images_from_folder_to_project( project=self._project["name"], folder_path=self.folder_path ) @@ -42,7 +42,7 @@ def test_folder_limitations(self, *_): return_value=project_limit_response, ) def test_project_limitations(self, *_): - with self.assertRaisesRegexp(AppException, UPLOAD_PROJECT_LIMIT_ERROR_MESSAGE): + with self.assertRaisesRegex(AppException, UPLOAD_PROJECT_LIMIT_ERROR_MESSAGE): _, _, __ = sa.upload_images_from_folder_to_project( project=self._project["name"], folder_path=self.folder_path ) @@ -52,7 +52,7 @@ def test_project_limitations(self, *_): return_value=user_limit_response, ) def test_user_limitations(self, *_): - with self.assertRaisesRegexp(AppException, UPLOAD_USER_LIMIT_ERROR_MESSAGE): + with self.assertRaisesRegex(AppException, UPLOAD_USER_LIMIT_ERROR_MESSAGE): _, _, __ = sa.upload_images_from_folder_to_project( project=self._project["name"], folder_path=self.folder_path ) diff --git a/tests/integration/work_management/data_set.py b/tests/integration/work_management/data_set.py index ead40226d..1694344a1 100644 --- a/tests/integration/work_management/data_set.py +++ b/tests/integration/work_management/data_set.py @@ -2,7 +2,6 @@ from src.superannotate.lib.core.enums import CustomFieldType - CUSTOM_FIELD_PAYLOADS = [ { "name": "SDK_test_text", diff --git a/tests/integration/work_management/test_contributors_categories.py b/tests/integration/work_management/test_contributors_categories.py index 9679d8d5a..fdeb82a91 100644 --- a/tests/integration/work_management/test_contributors_categories.py +++ b/tests/integration/work_management/test_contributors_categories.py @@ -253,7 +253,7 @@ def test_set_categories_with_invalid_contributor(self): test_categories = ["Category_A", "Category_B", "Category_C"] sa.create_categories(project=self.PROJECT_NAME, categories=test_categories) - with self.assertRaisesRegexp(AppException, "Contributors not found.") as cm: + with self.assertRaisesRegex(AppException, "Contributors not found.") as cm: sa.set_contributors_categories( project=self.PROJECT_NAME, contributors=[self.scapegoat["email"], "invalid_email@mail.com"], diff --git a/tests/integration/work_management/test_pause_resume_user_activity.py b/tests/integration/work_management/test_pause_resume_user_activity.py index 6c595832b..48f36b994 100644 --- a/tests/integration/work_management/test_pause_resume_user_activity.py +++ b/tests/integration/work_management/test_pause_resume_user_activity.py @@ -2,7 +2,6 @@ from src.superannotate import SAClient from tests.integration.base import BaseTestCase - sa = SAClient() @@ -42,7 +41,7 @@ def test_pause_and_resume_user_activity(self): == f"INFO:sa:User with email {scapegoat['email']} has been successfully paused" f" from the specified projects: {[self.PROJECT_NAME]}." ) - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "The user does not have the required permissions for this assignment.", ): diff --git a/tests/integration/work_management/test_project_categories.py b/tests/integration/work_management/test_project_categories.py index 87a3328f8..1d873de73 100644 --- a/tests/integration/work_management/test_project_categories.py +++ b/tests/integration/work_management/test_project_categories.py @@ -162,7 +162,7 @@ def test_delete_all_categories_with_asterisk(self): assert len(categories) == 0 def test_delete_categories_with_empty_list(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Categories should be a list of strings or '*'" ): sa.remove_categories(project=self.PROJECT_NAME, categories=[]) @@ -175,7 +175,7 @@ def test_delete_invalid_categories(self): ) def test_create_categories_with_empty_categories(self): - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Categories should be a list of strings." ): sa.create_categories(project=self.PROJECT_NAME, categories=[]) diff --git a/tests/integration/work_management/test_project_custom_fields.py b/tests/integration/work_management/test_project_custom_fields.py index fea40a5b8..e30e77698 100644 --- a/tests/integration/work_management/test_project_custom_fields.py +++ b/tests/integration/work_management/test_project_custom_fields.py @@ -95,11 +95,11 @@ def test_set_project_custom_field_validation( error_template_select = error_template + "\nValid options are: {options}." # test for text - with self.assertRaisesRegexp(AppException, error_template.format(type="str")): + with self.assertRaisesRegex(AppException, error_template.format(type="str")): sa.set_project_custom_field(self.PROJECT_NAME, "SDK_test_text", 123) # test for numeric - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template.format(type="numeric") ): sa.set_project_custom_field( @@ -107,7 +107,7 @@ def test_set_project_custom_field_validation( ) # test for date_picker - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template.format(type="numeric") ): sa.set_project_custom_field( @@ -115,7 +115,7 @@ def test_set_project_custom_field_validation( ) # test for multi_select - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template_select.format(type="list", options="option1, option2"), ): @@ -124,7 +124,7 @@ def test_set_project_custom_field_validation( ) # test for select - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template_select.format(type="str", options="option1, option2"), ): @@ -164,11 +164,11 @@ def test_list_projects_by_native_fields(self): ) def test_list_projects_by_native_invalid_fields(self): - with self.assertRaisesRegexp(AppException, "Invalid filter param provided."): + with self.assertRaisesRegex(AppException, "Invalid filter param provided."): sa.list_projects(name__in="text", status__gte="text") - with self.assertRaisesRegexp(AppException, "Invalid filter param provided."): + with self.assertRaisesRegex(AppException, "Invalid filter param provided."): sa.list_projects(name__invalid="text") - with self.assertRaisesRegexp(AppException, "Invalid filter param provided."): + with self.assertRaisesRegex(AppException, "Invalid filter param provided."): sa.list_projects(invalid_field="text") def test_list_projects_by_custom_fields(self): @@ -320,9 +320,10 @@ 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.assertRaisesRegexp(AppException, "Invalid filter param provided."): + with self.assertRaisesRegex(AppException, "Invalid filter param provided."): sa.list_projects( include=["custom_fields"], custom_field__INVALID_FIELD="text", @@ -331,5 +332,5 @@ def test_list_projects_by_custom_invalid_field(self): # TODO BED issue (custom_field filter without join) def test_list_projects_by_custom_fields_without_join(self): self._set_custom_field_values() - with self.assertRaisesRegexp(AppException, "Internal server error"): + with self.assertRaisesRegex(AppException, "Internal server error"): assert sa.list_projects(custom_field__SDK_test_numeric=123) diff --git a/tests/integration/work_management/test_user_custom_fields.py b/tests/integration/work_management/test_user_custom_fields.py index 417363734..b4ab616de 100644 --- a/tests/integration/work_management/test_user_custom_fields.py +++ b/tests/integration/work_management/test_user_custom_fields.py @@ -7,7 +7,6 @@ from src.superannotate.lib.core.enums import CustomFieldEntityEnum from tests.integration.work_management.data_set import CUSTOM_FIELD_PAYLOADS - sa = SAClient() @@ -180,17 +179,17 @@ def test_list_users(self): # by role assert len(sa.list_users(role="contributor")) == len(all_contributors) assert len(sa.list_users(role__in=["contributor"])) == len(all_contributors) - with self.assertRaisesRegexp(AppException, "Invalid user role provided."): + with self.assertRaisesRegex(AppException, "Invalid user role provided."): sa.list_users(role__in=["invalid_role"]) # by state assert len(sa.list_users(state="Confirmed")) == len(all_confirmed) assert len(sa.list_users(state__in=["Pending"])) == len(all_pending) - with self.assertRaisesRegexp(AppException, "Invalid user state provided."): + with self.assertRaisesRegex(AppException, "Invalid user state provided."): assert len(sa.list_users(state__in=["invalid_state"])) == len(all_pending) def test_get_user_metadata_invalid(self): - with self.assertRaisesRegexp(AppException, "User not found."): + with self.assertRaisesRegex(AppException, "User not found."): sa.get_user_metadata("invalid_user") def test_set_user_custom_field_validation(self): @@ -203,17 +202,17 @@ def test_set_user_custom_field_validation(self): scapegoat = [u for u in users if u["role"] == "Contributor"][0] # test for Team_Owner - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Setting custom fields for the Team Owner is not allowed." ): sa.set_user_custom_field(team_owner["email"], "SDK_test_text", 123) # test for text - with self.assertRaisesRegexp(AppException, error_template.format(type="str")): + with self.assertRaisesRegex(AppException, error_template.format(type="str")): sa.set_user_custom_field(scapegoat["email"], "SDK_test_text", 123) # test for numeric - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template.format(type="numeric") ): sa.set_user_custom_field( @@ -221,7 +220,7 @@ def test_set_user_custom_field_validation(self): ) # test for date_picker - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template.format(type="numeric") ): sa.set_user_custom_field( @@ -229,7 +228,7 @@ def test_set_user_custom_field_validation(self): ) # test for multi_select - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template_select.format(type="list", options="option1, option2"), ): @@ -238,7 +237,7 @@ def test_set_user_custom_field_validation(self): ) # test for select - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, error_template_select.format(type="str", options="option1, option2"), ): diff --git a/tests/integration/work_management/test_user_scoring.py b/tests/integration/work_management/test_user_scoring.py index 21c978d47..fee2a2371 100644 --- a/tests/integration/work_management/test_user_scoring.py +++ b/tests/integration/work_management/test_user_scoring.py @@ -189,7 +189,7 @@ def test_set_get_scores_negative_cases(self): self._attach_item(self.PROJECT_NAME, item_name) # case when one of wight and value is None - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Weight and Value must both be set or both be None." ): sa.set_user_scores( @@ -205,7 +205,7 @@ def test_set_get_scores_negative_cases(self): ], ) - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Weight and Value must both be set or both be None." ): sa.set_user_scores( @@ -222,7 +222,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid keys - with self.assertRaisesRegexp(AppException, "Invalid Scores."): + with self.assertRaisesRegex(AppException, "Invalid Scores."): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, @@ -238,7 +238,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid score name - with self.assertRaisesRegexp(AppException, "Invalid component_id provided"): + with self.assertRaisesRegex(AppException, "Invalid component_id provided"): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, @@ -253,7 +253,7 @@ def test_set_get_scores_negative_cases(self): ) # case without value key in score - with self.assertRaisesRegexp(AppException, "Invalid Scores."): + with self.assertRaisesRegex(AppException, "Invalid Scores."): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, @@ -267,7 +267,7 @@ def test_set_get_scores_negative_cases(self): ) # case with duplicated acore names - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Component IDs in scores data must be unique." ): sa.set_user_scores( @@ -289,7 +289,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid weight - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, "Please provide a valid number greater than 0" ): sa.set_user_scores( @@ -306,7 +306,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid scored_user - with self.assertRaisesRegexp(AppException, "User not found."): + with self.assertRaisesRegex(AppException, "User not found."): sa.set_user_scores( project=self.PROJECT_NAME, item=item_name, @@ -321,7 +321,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid item - with self.assertRaisesRegexp(AppException, "Item not found."): + with self.assertRaisesRegex(AppException, "Item not found."): sa.set_user_scores( project=self.PROJECT_NAME, item="invalid_item_name", @@ -336,7 +336,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid project - with self.assertRaisesRegexp(AppException, "Project not found."): + with self.assertRaisesRegex(AppException, "Project not found."): sa.set_user_scores( project="invalid_project_name", item=item_name, @@ -351,7 +351,7 @@ def test_set_get_scores_negative_cases(self): ) # case with invalid folder - with self.assertRaisesRegexp(AppException, "Folder not found."): + with self.assertRaisesRegex(AppException, "Folder not found."): sa.set_user_scores( project=(self.PROJECT_NAME, "invalid_folder_name"), item=item_name, diff --git a/tests/unit/test_classes_serialization.py b/tests/unit/test_classes_serialization.py index 540f1d8e2..78df1076c 100644 --- a/tests/unit/test_classes_serialization.py +++ b/tests/unit/test_classes_serialization.py @@ -3,8 +3,8 @@ from typing import List from unittest import TestCase -from lib.core.pydantic_v1 import parse_obj_as -from lib.core.pydantic_v1 import ValidationError +from pydantic import TypeAdapter +from pydantic import ValidationError from superannotate.lib.app.serializers import BaseSerializer from superannotate.lib.core.entities.classes import AnnotationClassEntity from superannotate.lib.core.entities.classes import AttributeGroup @@ -23,14 +23,18 @@ def large_json_path(self): def test_type_user_serializer(self): with open(self.large_json_path) as file: data_json = json.load(file) - classes = parse_obj_as(List[AnnotationClassEntity], data_json) + classes = TypeAdapter(List[AnnotationClassEntity]).validate_python( + data_json + ) serializer_data = BaseSerializer.serialize_iterable(classes) assert all([isinstance(i.get("type"), str) for i in serializer_data]) def test_type_api_serializer(self): with open(self.large_json_path) as file: data_json = json.load(file) - classes = parse_obj_as(List[AnnotationClassEntity], data_json) + classes = TypeAdapter(List[AnnotationClassEntity]).validate_python( + data_json + ) serializer_data = json.loads(json.dumps(classes, cls=PydanticEncoder)) assert all([isinstance(i.get("type"), int) for i in serializer_data]) @@ -74,14 +78,16 @@ def test_group_type_wrong_arg(self): ], ) except ValidationError as e: + # Pydantic v2 error message format assert [ "group_type", - "Available", - "values", - "are:", + "Input", + "should", + "be", "'radio',", "'checklist',", "'numeric',", - "'text',", + "'text'", + "or", "'ocr'", ] == wrap_error(e).split() diff --git a/tests/unit/test_conditions.py b/tests/unit/test_conditions.py index c4349e391..00e2fa165 100644 --- a/tests/unit/test_conditions.py +++ b/tests/unit/test_conditions.py @@ -9,16 +9,16 @@ class TestCondition(TestCase): def test_query_build(self): condition = Condition("created", "today", CONDITION_GE) - self.assertEquals(condition.build_query(), "created>=today") + self.assertEqual(condition.build_query(), "created>=today") def test_multiple_condition_query_build(self): condition = Condition("id", 1, CONDITION_EQ) | Condition("id", 2, CONDITION_GE) - self.assertEquals(condition.build_query(), "id=1|id>=2") + self.assertEqual(condition.build_query(), "id=1|id>=2") def test_multiple_condition_query_build_from_tuple(self): condition = Condition("id", 1, CONDITION_EQ) | Condition("id", 2, CONDITION_GE) condition &= Condition("id", 5, CONDITION_EQ) & Condition("id", 7, CONDITION_EQ) - self.assertEquals(condition.build_query(), "id=1|id>=2&id=5&id=7") + self.assertEqual(condition.build_query(), "id=1|id>=2&id=5&id=7") def test_(self): folder_name = "name" diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index b53fd1ed1..7b1a8aeca 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.assertRaisesRegexp(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.assertRaisesRegexp( - 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.assertRaisesRegexp(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,14 +155,12 @@ 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.assertRaisesRegexp( - AppException, r"(\s+)SA_TOKEN(\s+)Invalid token." - ): + with self.assertRaisesRegex(AppException, r"Invalid token\."): SAClient(**kwargs) def test_invalid_config_path(self): _path = "something" - with self.assertRaisesRegexp( + with self.assertRaisesRegex( AppException, f"SuperAnnotate config file {_path} not found." ): SAClient(config_path=_path) diff --git a/tests/unit/test_jsx_conditions.py b/tests/unit/test_jsx_conditions.py index 35737b995..c6f210421 100644 --- a/tests/unit/test_jsx_conditions.py +++ b/tests/unit/test_jsx_conditions.py @@ -9,14 +9,14 @@ class TestCondition(TestCase): def test_query_build(self): condition = Filter("created", "today", OperatorEnum.GT) - self.assertEquals(condition.build_query(), "filter=created||$gt||today") + self.assertEqual(condition.build_query(), "filter=created||$gt||today") def test_multiple_query_build(self): query = Filter("id", "1,2,3", OperatorEnum.IN) & OrFilter( "id", 2, OperatorEnum.EQ ) query &= Join("metadata") & Join("fields", ["field1", "field2"]) - self.assertEquals( + self.assertEqual( "filter=id||$in||1%2C2%2C3&or=id||$eq||2&join=metadata&join=fields||field1,field2", query.build_query(), )