From daeb61a491b1630f41c1a9280317db5a5b2b0da4 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 22 Dec 2022 14:41:05 +0400 Subject: [PATCH 01/18] pep8 fix --- .../lib/app/analytics/aggregators.py | 23 ++++--- src/superannotate/lib/app/analytics/common.py | 16 +++-- .../lib/app/interface/sdk_interface.py | 21 +++--- src/superannotate/lib/app/serializers.py | 2 + .../lib/core/entities/__init__.py | 6 +- src/superannotate/lib/core/entities/items.py | 4 ++ src/superannotate/lib/core/service_types.py | 7 ++ .../lib/core/usecases/folders.py | 8 +-- src/superannotate/lib/core/usecases/items.py | 13 ++-- src/superannotate/lib/core/usecases/models.py | 64 +++++++++---------- .../lib/core/usecases/projects.py | 12 ++-- .../lib/infrastructure/controller.py | 46 +++++-------- .../lib/infrastructure/services/folder.py | 9 +-- .../lib/infrastructure/services/item.py | 24 +++---- .../lib/infrastructure/services/project.py | 5 +- 15 files changed, 128 insertions(+), 132 deletions(-) diff --git a/src/superannotate/lib/app/analytics/aggregators.py b/src/superannotate/lib/app/analytics/aggregators.py index 173a05d91..83964e41e 100644 --- a/src/superannotate/lib/app/analytics/aggregators.py +++ b/src/superannotate/lib/app/analytics/aggregators.py @@ -9,13 +9,13 @@ import lib.core as constances import pandas as pd from lib.app.exceptions import AppException -from lib.core import ATTACHED_VIDEO_ANNOTATION_POSTFIX from lib.core import PIXEL_ANNOTATION_POSTFIX from lib.core import VECTOR_ANNOTATION_POSTFIX from superannotate.logger import get_default_logger logger = get_default_logger() + @dataclass class ImageRowData: itemName: str = None @@ -50,6 +50,7 @@ class ImageRowData: commentResolved: str = None tag: str = None + @dataclass class VideoRawData: itemName: str = None @@ -135,9 +136,9 @@ class DataAggregator: ), "tag": lambda annotation: None, "mask": lambda annotation: {"parts": annotation["parts"]}, - "template": lambda annotation : None, + "template": lambda annotation: None, "rbbox": lambda annotation: annotation["points"], - "comment_inst": lambda annotation: annotation["points"] + "comment_inst": lambda annotation: annotation["points"], } def __init__( @@ -204,10 +205,9 @@ def aggregate_annotations_as_df(self): self.check_classes_path() annotation_paths = self.get_annotation_paths() - if self.project_type in ( constances.ProjectType.VECTOR, - constances.ProjectType.PIXEL + constances.ProjectType.PIXEL, ): return self.aggregate_image_annotations_as_df(annotation_paths) elif self.project_type is constances.ProjectType.VIDEO: @@ -215,7 +215,6 @@ def aggregate_annotations_as_df(self): elif self.project_type is constances.ProjectType.DOCUMENT: return self.aggregate_document_annotations_as_df(annotation_paths) - def __add_attributes_to_raws(self, raws, attributes, element_raw): for attribute_id, attribute in enumerate(attributes): attribute_raw = copy.copy(element_raw) @@ -388,10 +387,10 @@ def aggregate_image_annotations_as_df(self, annotations_paths: List[str]): for annotation_path in annotations_paths: row_data = ImageRowData() annotation_json = None - with open(annotation_path, 'r') as fp: + with open(annotation_path) as fp: annotation_json = json.load(fp) parts = Path(annotation_path).name.split(self._annotation_suffix) - row_data = self.__fill_image_metadata(row_data, annotation_json['metadata']) + row_data = self.__fill_image_metadata(row_data, annotation_json["metadata"]) annotation_instance_id = 0 # include comments @@ -408,7 +407,7 @@ def aggregate_image_annotations_as_df(self, annotations_paths: List[str]): tag_row.rag = tag rows.append(tag_row) - #Instances + # Instances for idx, annotation in enumerate(annotation_json["instances"]): instance_row = copy.copy(row_data) annotation_type = annotation.get("type", "mask") @@ -462,7 +461,8 @@ def aggregate_image_annotations_as_df(self, annotations_paths: List[str]): attribute_name not in class_group_name_to_values[annotation_class_name][ attribute_group - ] and group_id not in freestyle_attributes + ] + and group_id not in freestyle_attributes ): logger.warning( f"Annotation class group value {attribute_name} not in classes json. Skipping." @@ -473,7 +473,6 @@ def aggregate_image_annotations_as_df(self, annotations_paths: List[str]): attribute_row.attributeGroupName = attribute_group attribute_row.attributeName = attribute_name - rows.append(attribute_row) num_added += 1 @@ -486,7 +485,7 @@ def aggregate_image_annotations_as_df(self, annotations_paths: List[str]): @staticmethod def __fill_image_metadata(raw_data, metadata): - raw_data.itemName = metadata.get('name') + raw_data.itemName = metadata.get("name") raw_data.itemHeight = metadata.get("height") raw_data.itemWidth = metadata.get("width") raw_data.itemStatus = metadata.get("status") diff --git a/src/superannotate/lib/app/analytics/common.py b/src/superannotate/lib/app/analytics/common.py index b5f753792..c0ea7b483 100644 --- a/src/superannotate/lib/app/analytics/common.py +++ b/src/superannotate/lib/app/analytics/common.py @@ -398,6 +398,7 @@ def instance_consensus(inst_1, inst_2): return score + def calculate_tag_consensus(image_df): column_names = [ "creatorEmail", @@ -406,14 +407,14 @@ def calculate_tag_consensus(image_df): "className", "folderName", "attributeGroupName", - "attributeName" + "attributeName", ] image_data = {} for column_name in column_names: image_data[column_name] = [] - image_df=image_df.reset_index() + image_df = image_df.reset_index() image_data["score"] = [] for i, irow in image_df.iterrows(): for c in column_names: @@ -422,10 +423,15 @@ def calculate_tag_consensus(image_df): for j, jrow in image_df.iterrows(): if i == j: continue - if (irow["className"] == jrow["className"]) and irow["attributeGroupName"] == jrow["attributeGroupName"] and irow["attributeName"] == jrow["attributeName"]: - image_data["score"][i]+=1 + if ( + (irow["className"] == jrow["className"]) + and irow["attributeGroupName"] == jrow["attributeGroupName"] + and irow["attributeName"] == jrow["attributeName"] + ): + image_data["score"][i] += 1 return image_data + def consensus(df, item_name, annot_type): """Helper function that computes consensus score for instances of a single image: @@ -484,7 +490,7 @@ def consensus(df, item_name, annot_type): inst = Polygon(shapely_format) elif annot_type == "point": inst = Point(inst_data["x"], inst_data["y"]) - if annot_type != "tag" and inst.is_valid: + if annot_type != "tag" and inst.is_valid: projects_shaply_objs[row["folderName"]].append( (inst, row["className"], row["creatorEmail"], row["attributes"]) ) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 7ebd93462..9dabdbdd2 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -35,10 +35,10 @@ from lib.app.interface.types import Setting from lib.app.serializers import BaseSerializer from lib.app.serializers import FolderSerializer +from lib.app.serializers import ItemSerializer from lib.app.serializers import ProjectSerializer from lib.app.serializers import SettingsSerializer from lib.app.serializers import TeamSerializer -from lib.app.serializers import ItemSerializer from lib.core import entities from lib.core import LIMITED_FUNCTIONS from lib.core.conditions import Condition @@ -95,11 +95,12 @@ def get_folder_by_id(self, project_id: int, folder_id: int): """ response = self.controller.get_folder_by_id( - folder_id=folder_id, - project_id=project_id + folder_id=folder_id, project_id=project_id ) - return FolderSerializer(response).serialize(exclude={"completedCount", "is_root"}) + return FolderSerializer(response).serialize( + exclude={"completedCount", "is_root"} + ) def get_project_by_id(self, project_id: int): """Returns the project @@ -107,9 +108,7 @@ def get_project_by_id(self, project_id: int): :return: folder information :rtype: dict """ - response = self.controller.get_project_by_id( - project_id=project_id - ) + response = self.controller.get_project_by_id(project_id=project_id) return ProjectSerializer(response.data).serialize() @@ -122,12 +121,10 @@ def get_item_by_id(self, project_id: int, item_id: int): """ response = self.controller.get_item_by_id( - item_id=item_id, - project_id=project_id + item_id=item_id, project_id=project_id ) - return ItemSerializer(response).serialize(exclude = {"url", "meta"}) - + return ItemSerializer(response).serialize(exclude={"url", "meta"}) def get_team_metadata(self): """Returns team metadata @@ -499,7 +496,7 @@ def get_project_metadata( include_settings: Optional[StrictBool] = False, include_workflow: Optional[StrictBool] = False, include_contributors: Optional[StrictBool] = False, - include_complete_image_count: Optional[StrictBool] = False + include_complete_image_count: Optional[StrictBool] = False, ): """Returns project metadata diff --git a/src/superannotate/lib/app/serializers.py b/src/superannotate/lib/app/serializers.py index 53c7d8fa3..032811fd7 100644 --- a/src/superannotate/lib/app/serializers.py +++ b/src/superannotate/lib/app/serializers.py @@ -165,6 +165,7 @@ def serialize(self): self.data["value"] = constance.ImageQuality.get_name(self.data["value"]) return self.data + class ItemSerializer(BaseSerializer): def serialize( self, @@ -177,6 +178,7 @@ def serialize( return data + class EntitySerializer: @classmethod def serialize( diff --git a/src/superannotate/lib/core/entities/__init__.py b/src/superannotate/lib/core/entities/__init__.py index 668a7eb36..8c8d95c76 100644 --- a/src/superannotate/lib/core/entities/__init__.py +++ b/src/superannotate/lib/core/entities/__init__.py @@ -3,12 +3,12 @@ from lib.core.entities.classes import AnnotationClassEntity from lib.core.entities.folder import FolderEntity from lib.core.entities.integrations import IntegrationEntity +from lib.core.entities.items import ClassificationEntity from lib.core.entities.items import DocumentEntity from lib.core.entities.items import ImageEntity -from lib.core.entities.items import VideoEntity -from lib.core.entities.items import ClassificationEntity -from lib.core.entities.items import TiledEntity from lib.core.entities.items import PointCloudEntity +from lib.core.entities.items import TiledEntity +from lib.core.entities.items import VideoEntity from lib.core.entities.project import AttachmentEntity from lib.core.entities.project import MLModelEntity from lib.core.entities.project import ProjectEntity diff --git a/src/superannotate/lib/core/entities/items.py b/src/superannotate/lib/core/entities/items.py index f3e0090fb..dc3797ad9 100644 --- a/src/superannotate/lib/core/entities/items.py +++ b/src/superannotate/lib/core/entities/items.py @@ -28,18 +28,22 @@ class VideoEntity(BaseItemEntity): class Config: extra = Extra.ignore + class DocumentEntity(BaseItemEntity): class Config: extra = Extra.ignore + class TiledEntity(BaseItemEntity): class Config: extra = Extra.ignore + class ClassificationEntity(BaseItemEntity): class Config: extra = Extra.ignore + class PointCloudEntity(BaseItemEntity): class Config: extra = Extra.ignore diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index e0a305407..c766c1654 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -167,24 +167,31 @@ def set_error(self, value: Union[dict, str]): class ImageResponse(ServiceResponse): data: entities.ImageEntity = None + class VideoResponse(ServiceResponse): data: entities.VideoEntity = None + class DocumentResponse(ServiceResponse): data: entities.DocumentEntity = None + class TiledResponse(ServiceResponse): data: entities.TiledEntity = None + class ClassificationResponse(ServiceResponse): data: entities.ClassificationEntity = None + class PointCloudResponse(ServiceResponse): data: entities.PointCloudEntity = None + class TeamResponse(ServiceResponse): data: entities.TeamEntity = None + class ModelListResponse(ServiceResponse): data: List[entities.AnnotationClassEntity] = None diff --git a/src/superannotate/lib/core/usecases/folders.py b/src/superannotate/lib/core/usecases/folders.py index 321cb131f..340231591 100644 --- a/src/superannotate/lib/core/usecases/folders.py +++ b/src/superannotate/lib/core/usecases/folders.py @@ -25,12 +25,12 @@ def __init__(self, project_id, folder_id, team_id, service_provider): def execute(self): try: response = self._service_provider.folders.get_by_id( - folder_id = self._folder_id, - project_id = self._project_id, - team_id = self._team_id + folder_id=self._folder_id, + project_id=self._project_id, + team_id=self._team_id, ) except AppException as e: - self._response.errors=e + self._response.errors = e else: self._response.data = response.data diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 4c0998bb9..483749ddf 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -34,6 +34,7 @@ logger = get_default_logger() + class GetItemByIDUseCase(BaseUseCase): def __init__(self, item_id, project, service_provider): self._item_id = item_id @@ -41,14 +42,15 @@ def __init__(self, item_id, project, service_provider): self._service_provider = service_provider super().__init__() - def execute(self, ): + def execute( + self, + ): try: response = self._service_provider.items.get_by_id( - item_id = self._item_id, - project_id = self._project.id, - project_type = self._project.type - + item_id=self._item_id, + project_id=self._project.id, + project_type=self._project.type, ) except AppException as e: self._response.errors = e @@ -60,6 +62,7 @@ def execute(self, ): return self._response + class GetItem(BaseReportableUseCase): def __init__( self, diff --git a/src/superannotate/lib/core/usecases/models.py b/src/superannotate/lib/core/usecases/models.py index 2c58f7886..636e95969 100644 --- a/src/superannotate/lib/core/usecases/models.py +++ b/src/superannotate/lib/core/usecases/models.py @@ -1,12 +1,12 @@ import concurrent.futures import os.path +import platform import tempfile import time -import platform import zipfile from pathlib import Path -from typing import List from tempfile import TemporaryDirectory +from typing import List import boto3 import lib.core as constances @@ -15,8 +15,8 @@ from botocore.exceptions import ClientError from lib.app.analytics.aggregators import DataAggregator from lib.app.analytics.common import aggregate_image_annotations_as_df -from lib.app.analytics.common import consensus_plot from lib.app.analytics.common import consensus +from lib.app.analytics.common import consensus_plot from lib.core.conditions import Condition from lib.core.conditions import CONDITION_EQ as EQ from lib.core.entities import FolderEntity @@ -28,12 +28,11 @@ from lib.core.exceptions import AppValidationException from lib.core.reporter import Reporter from lib.core.serviceproviders import BaseServiceProvider +from lib.core.usecases.annotations import DownloadAnnotations from lib.core.usecases.base import BaseReportableUseCase from lib.core.usecases.base import BaseUseCase -from lib.core.usecases.folders import GetFolderUseCase -from lib.core.usecases.annotations import GetAnnotations, DownloadAnnotations from lib.core.usecases.classes import DownloadAnnotationClassesUseCase -from lib.core.reporter import Reporter +from lib.core.usecases.folders import GetFolderUseCase from superannotate.logger import get_default_logger logger = get_default_logger() @@ -433,9 +432,9 @@ def __init__( for folder_name in folder_names: get_folder_uc = GetFolderUseCase( - project = self._project, - service_provider = service_provider, - folder_name=folder_name + project=self._project, + service_provider=service_provider, + folder_name=folder_name, ) folder = get_folder_uc.execute().data if not folder: @@ -445,45 +444,44 @@ def __init__( def _download_annotations(self, destination): reporter = Reporter( - log_info=False, - log_warning=False, - log_debug=False, - disable_progress_bar=True + log_info=False, + log_warning=False, + log_debug=False, + disable_progress_bar=True, ) classes_dir = Path(destination) / "classes" classes_dir.mkdir() DownloadAnnotationClassesUseCase( - reporter = reporter, - download_path = classes_dir, - project = self._project, - service_provider = self.service_provider + reporter=reporter, + download_path=classes_dir, + project=self._project, + service_provider=self.service_provider, ).execute() for folder in self._folders: download_annotations_uc = DownloadAnnotations( - reporter = reporter, - project = self._project, - folder = folder, - item_names = self._image_list, - destination = destination, #Destination unknown known known - service_provider = self.service_provider, - recursive = False - ) + reporter=reporter, + project=self._project, + folder=folder, + item_names=self._image_list, + destination=destination, # Destination unknown known known + service_provider=self.service_provider, + recursive=False, + ) tmp = download_annotations_uc.execute() if tmp.errors: raise AppException(tmp.errors) return tmp.data - def execute(self): with TemporaryDirectory() as temp_dir: export_path = self._download_annotations(temp_dir) aggregator = DataAggregator( - project_type = self._project.type, - folder_names = self._folder_names, - project_root = export_path, + project_type=self._project.type, + folder_names=self._folder_names, + project_root=export_path, ) project_df = aggregator.aggregate_annotations_as_df() @@ -497,10 +495,8 @@ def execute(self): all_projects_df["itemName"].isin(self._image_list) ] - all_projects_df.query("type == '" + self._instance_type + "'", inplace=True) - def aggregate_attributes(instance_df): def attribute_to_list(attribute_df): attribute_names = list(attribute_df["attributeName"]) @@ -540,14 +536,12 @@ def attribute_to_list(attribute_df): unique_images = all_projects_df["itemName"].unique() all_consensus_data = [] for image_name in unique_images: - image_data = consensus( - all_projects_df, image_name, self._instance_type - ) + image_data = consensus(all_projects_df, image_name, self._instance_type) all_consensus_data.append(pd.DataFrame(image_data)) consensus_df = pd.concat(all_consensus_data, ignore_index=True) if self._instance_type == "tag": - consensus_df["score"]/=(len(self._folder_names) - 1) + consensus_df["score"] /= len(self._folder_names) - 1 self._response.data = consensus_df return self._response diff --git a/src/superannotate/lib/core/usecases/projects.py b/src/superannotate/lib/core/usecases/projects.py index ac5615bca..3166156e2 100644 --- a/src/superannotate/lib/core/usecases/projects.py +++ b/src/superannotate/lib/core/usecases/projects.py @@ -24,27 +24,31 @@ logger = get_default_logger() + class GetProjectByIDUseCase(BaseUseCase): def __init__(self, project_id, service_provider): self._project_id = project_id - self._service_provider=service_provider + self._service_provider = service_provider super().__init__() def execute(self): try: - self._response.data= self._service_provider.projects.get_by_id( - project_id = self._project_id + self._response.data = self._service_provider.projects.get_by_id( + project_id=self._project_id ) except AppException as e: self._response.errors = e else: if not self._response.data.data: - self._response.errors = AppException("Either the specified project does not exist or you do not have permission to view it") + self._response.errors = AppException( + "Either the specified project does not exist or you do not have permission to view it" + ) return self._response + class GetProjectsUseCase(BaseUseCase): def __init__( self, diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index c9c968e8d..ecbc7c540 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -49,8 +49,7 @@ def __init__(self, service_provider: ServiceProvider): class ProjectManager(BaseManager): def get_by_id(self, project_id): use_case = usecases.GetProjectByIDUseCase( - project_id=project_id, - service_provider=self.service_provider + project_id=project_id, service_provider=self.service_provider ) response = use_case.execute() return response @@ -315,7 +314,7 @@ def get_by_id(self, folder_id, project_id, team_id): folder_id=folder_id, project_id=project_id, team_id=team_id, - service_provider = self.service_provider + service_provider=self.service_provider, ) result = use_case.execute() return result @@ -373,15 +372,14 @@ def get_by_name( ) return use_case.execute() - def get_by_id(self, item_id: int, project : ProjectEntity): + def get_by_id(self, item_id: int, project: ProjectEntity): use_case = usecases.GetItemByIDUseCase( - item_id = item_id, - project = project.data, - service_provider = self.service_provider + item_id=item_id, + project=project.data, + service_provider=self.service_provider, ) return use_case.execute() - def list( self, project: ProjectEntity, @@ -859,13 +857,9 @@ def set_default(cls, obj): cls.DEFAULT = obj return cls.DEFAULT - def get_folder_by_id( - self, folder_id: int, project_id: int - ) -> FolderEntity: - response= self.folders.get_by_id( - folder_id = folder_id, - project_id = project_id, - team_id = self.team_id + def get_folder_by_id(self, folder_id: int, project_id: int) -> FolderEntity: + response = self.folders.get_by_id( + folder_id=folder_id, project_id=project_id, team_id=self.team_id ) if response.errors: @@ -873,32 +867,22 @@ def get_folder_by_id( return response.data - def get_project_by_id( - self, project_id: int - )->ProjectEntity: - response = self.projects.get_by_id( - project_id = project_id - ) + def get_project_by_id(self, project_id: int) -> ProjectEntity: + response = self.projects.get_by_id(project_id=project_id) if response.errors: raise AppException(response.errors) return response.data - def get_item_by_id( - self, item_id: int, project_id:int - ): - project=self.get_project_by_id(project_id = project_id) - response = self.items.get_by_id( - item_id = item_id, - project = project - ) + def get_item_by_id(self, item_id: int, project_id: int): + project = self.get_project_by_id(project_id=project_id) + response = self.items.get_by_id(item_id=item_id, project=project) if response.errors: raise AppException(response.errors) return response.data - def get_project_folder_by_path( self, path: Union[str, Path] ) -> Tuple[ProjectEntity, FolderEntity]: @@ -1207,7 +1191,7 @@ def consensus( folder_names=folder_names, image_list=image_list, annotation_type=annot_type, - service_provider = self.service_provider + service_provider=self.service_provider, ) return use_case.execute() diff --git a/src/superannotate/lib/infrastructure/services/folder.py b/src/superannotate/lib/infrastructure/services/folder.py index e29c65118..2e381450b 100644 --- a/src/superannotate/lib/infrastructure/services/folder.py +++ b/src/superannotate/lib/infrastructure/services/folder.py @@ -16,16 +16,13 @@ class FolderService(BaseFolderService): URL_GET_BY_ID = "folder/getFolderById" def get_by_id(self, folder_id, project_id, team_id): - params = { - "team_id": team_id, - "folder_id": folder_id, - "project_id": project_id - } + params = {"team_id": team_id, "folder_id": folder_id, "project_id": project_id} response = self.client.request( - self.URL_GET_BY_ID, "get", params = params, content_type=FolderResponse + self.URL_GET_BY_ID, "get", params=params, content_type=FolderResponse ) return response + def get_by_name(self, project: entities.ProjectEntity, name: str): params = {"project_id": project.id, "name": name} return self.client.request( diff --git a/src/superannotate/lib/infrastructure/services/item.py b/src/superannotate/lib/infrastructure/services/item.py index ac52641ea..5ca36f318 100644 --- a/src/superannotate/lib/infrastructure/services/item.py +++ b/src/superannotate/lib/infrastructure/services/item.py @@ -4,19 +4,18 @@ from lib.core import entities from lib.core.conditions import Condition +from lib.core.enums import ProjectType from lib.core.exceptions import AppException from lib.core.exceptions import BackendError -from lib.core.service_types import ItemListResponse -from lib.core.service_types import DocumentResponse -from lib.core.service_types import ImageResponse from lib.core.service_types import ClassificationResponse -from lib.core.service_types import VideoResponse +from lib.core.service_types import ImageResponse +from lib.core.service_types import ItemListResponse from lib.core.service_types import PointCloudResponse from lib.core.service_types import TiledResponse +from lib.core.service_types import VideoResponse from lib.core.serviceproviders import BaseItemService from lib.core.types import Attachment from lib.core.types import AttachmentMeta -from lib.core.enums import ProjectType class ItemService(BaseItemService): @@ -32,27 +31,24 @@ class ItemService(BaseItemService): URL_GET_BY_ID = "image/{image_id}" PROJECT_TYPE_RESPOSE_MAP = { - ProjectType.VECTOR : ImageResponse, + ProjectType.VECTOR: ImageResponse, ProjectType.OTHER: ClassificationResponse, ProjectType.VIDEO: VideoResponse, ProjectType.TILED: TiledResponse, ProjectType.PIXEL: ImageResponse, - ProjectType.POINT_CLOUD: PointCloudResponse - + ProjectType.POINT_CLOUD: PointCloudResponse, } def get_by_id(self, item_id, project_id, project_type): - params = { - "project_id": project_id - } + params = {"project_id": project_id} content_type = self.PROJECT_TYPE_RESPOSE_MAP[project_type] - response = self.client.request( + response = self.client.request( url=self.URL_GET_BY_ID.format(image_id=item_id), - params = params, - content_type=content_type + params=params, + content_type=content_type, ) return response diff --git a/src/superannotate/lib/infrastructure/services/project.py b/src/superannotate/lib/infrastructure/services/project.py index 5e534d84c..c251da22b 100644 --- a/src/superannotate/lib/infrastructure/services/project.py +++ b/src/superannotate/lib/infrastructure/services/project.py @@ -24,7 +24,10 @@ class ProjectService(BaseProjectService): def get_by_id(self, project_id: int): params = {} result = self.client.request( - self.URL_GET_BY_ID.format(project_id=project_id), "get", params=params, content_type=ProjectResponse + self.URL_GET_BY_ID.format(project_id=project_id), + "get", + params=params, + content_type=ProjectResponse, ) return result From 3e21f48ad057d8831574456ddd5c825ddc0704cf Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 22 Dec 2022 19:14:45 +0400 Subject: [PATCH 02/18] pep8 fix --- src/superannotate/__init__.py | 1 - .../lib/app/interface/sdk_interface.py | 35 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index f6748b705..0c5c9de9f 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -5,7 +5,6 @@ __version__ = "4.4.8dev2" - sys.path.append(os.path.split(os.path.realpath(__file__))[0]) import logging.config # noqa diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index 038911265..0c86eaae4 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -97,11 +97,18 @@ def get_project_by_id(self, project_id: int): """ response = self.controller.get_project_by_id(project_id=project_id) + return ProjectSerializer(response.data).serialize() + def get_folder_by_id(self, project_id: int, folder_id: int): - """Returns the folder - :param folder_id: the id of the folder + """Returns the folder metadata + :param project_id: the id of the project - :return: folder information + :type project_id: int + + :param folder_id: the id of the folder + :type folder_id: int + + :return: folder metadata :rtype: dict """ @@ -113,21 +120,16 @@ def get_folder_by_id(self, project_id: int, folder_id: int): exclude={"completedCount", "is_root"} ) - def get_project_by_id(self, project_id: int): - """Returns the project - :param project_id: the id of the project - :return: folder information - :rtype: dict - """ - response = self.controller.get_project_by_id(project_id=project_id) + def get_item_by_id(self, project_id: int, item_id: int): + """Returns the item metadata - return ProjectSerializer(response.data).serialize() + :param project_id: the id of the project + :type project_id: int - def get_item_by_id(self, project_id: int, item_id: int): - """Returns the project :param item_id: the id of the item - :param project_id: the id of the project - :return: folder information + :type item_id: int + + :return: item metadata :rtype: dict """ @@ -157,10 +159,13 @@ def search_team_contributors( :param email: filter by email :type email: str + :param first_name: filter by first name :type first_name: str + :param last_name: filter by last name :type last_name: str + :param return_metadata: return metadata of contributors instead of names :type return_metadata: bool From f62168dca66075f0b49bb121f362c3afff07065c Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 22 Dec 2022 19:16:30 +0400 Subject: [PATCH 03/18] type fix --- src/superannotate/lib/infrastructure/services/item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/lib/infrastructure/services/item.py b/src/superannotate/lib/infrastructure/services/item.py index 5e26fa3fa..198b848bd 100644 --- a/src/superannotate/lib/infrastructure/services/item.py +++ b/src/superannotate/lib/infrastructure/services/item.py @@ -31,7 +31,7 @@ class ItemService(BaseItemService): URL_SET_ANNOTATION_STATUSES = "image/updateAnnotationStatusBulk" URL_GET_BY_ID = "image/{image_id}" - PROJECT_TYPE_RESPOSE_MAP = { + PROJECT_TYPE_RESPONSE_MAP = { ProjectType.VECTOR: ImageResponse, ProjectType.OTHER: ClassificationResponse, ProjectType.VIDEO: VideoResponse, From 538434e14ba2d3ceee368588cf08a9e21cd44e5c Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 27 Dec 2022 10:57:22 +0400 Subject: [PATCH 04/18] Fix upload annotations issue extension deletion --- pytest.ini | 2 +- .../lib/core/usecases/annotations.py | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pytest.ini b/pytest.ini index 1c2a50922..66f5fc363 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 auto --dist=loadscope \ No newline at end of file +addopts = -n auto --dist=loadscope \ No newline at end of file diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 34917974c..433795988 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -463,7 +463,7 @@ def get_name_path_mappings(annotation_paths): for item_path in annotation_paths: name_path_mappings[ - UploadAnnotationsFromFolderUseCase.extract_name(Path(item_path).name) + UploadAnnotationsFromFolderUseCase.extract_name(Path(item_path)) ] = item_path return name_path_mappings @@ -560,12 +560,15 @@ def chunks(data, size: int = 10000): yield {k: data[k] for k in islice(it, size)} @staticmethod - def extract_name(value: str): - return os.path.basename( - value.replace(constants.PIXEL_ANNOTATION_POSTFIX, "") - .replace(constants.VECTOR_ANNOTATION_POSTFIX, "") - .replace(constants.ATTACHED_VIDEO_ANNOTATION_POSTFIX, ""), - ) + def extract_name(value: Path): + + if constants.VECTOR_ANNOTATION_POSTFIX in value.name: + path = value.name.replace(constants.VECTOR_ANNOTATION_POSTFIX, "") + elif constants.PIXEL_ANNOTATION_POSTFIX in value.name: + path = value.name.replace(constants.PIXEL_ANNOTATION_POSTFIX, "") + else: + path = value.stem + return path def get_existing_name_item_mapping( self, name_path_mappings: Dict[str, str] From 8da4fe0e4941160e7dbd914bc0d424150615c9b5 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 3 Jan 2023 15:40:53 +0400 Subject: [PATCH 05/18] Added SAServer --- docs/source/cli.rst | 15 ++ docs/source/index.rst | 1 + docs/source/server.rst | 122 +++++++++++ pytest.ini | 2 +- requirements.txt | 1 + src/superannotate/__init__.py | 2 + .../lib/app/interface/cli_interface.py | 16 ++ src/superannotate/lib/app/server/__app.py | 12 ++ src/superannotate/lib/app/server/__init__.py | 4 + src/superannotate/lib/app/server/__wsgi.py | 16 ++ src/superannotate/lib/app/server/core.py | 199 ++++++++++++++++++ tests/integration/test_cli.py | 7 + 12 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 docs/source/server.rst create mode 100644 src/superannotate/lib/app/server/__app.py create mode 100644 src/superannotate/lib/app/server/__init__.py create mode 100644 src/superannotate/lib/app/server/__wsgi.py create mode 100644 src/superannotate/lib/app/server/core.py diff --git a/docs/source/cli.rst b/docs/source/cli.rst index f4a9bfc20..c2f77e8f5 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -36,6 +36,21 @@ To initialize CLI (and SDK) with team token: ---------- + +.. _ref_create_server: + +Creating a server +~~~~~~~~~~~~~~~~~~ + +This will create a directory by the given name in your current or provided directory: + +.. code-block:: bash + + superannotatecli create-server --name --path + +---------- + + .. _ref_create_project: Creating a project diff --git a/docs/source/index.rst b/docs/source/index.rst index 05f9272d9..10b21ca92 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -17,6 +17,7 @@ tutorial.sdk.rst superannotate.sdk.rst + server.rst cli.rst LICENSE.rst diff --git a/docs/source/server.rst b/docs/source/server.rst new file mode 100644 index 000000000..de491d879 --- /dev/null +++ b/docs/source/server.rst @@ -0,0 +1,122 @@ +.. _ref_server: + +SAServer Reference +====================================== + +.. contents:: + +The SAServer provides interface to create web API and run in development or production servers. + +This will create a directory by the given name in your current or provided directory: + +.. code-block:: bash + + superannotatecli create-server --name --path + +---------- + +Usage +---------------- + +SuperAnnotate Python SDK allows access to the platform without web browser: + +.. code-block:: python + + import random + from superannotate import SAClient + from superannotate import SAServer + + + app = SAServer() + sa_client = SAClient() + QA_EMAILS = [ + 'qa1@superannotate.com', 'qa2@superannotate.com', + 'qa3@superannotate.com', 'qa4@superannotate.com' + ] + + + @app.route("item_completed", methods=["post"]) + def index(request): + """ + Listening webhooks on items completed events form Superannotate automation + and is randomly assigned to qa + """ + project_id, folder_id = request.data['project_id'], request.data['folder_id'] + project = sa_client.get_project_by_id(project_id) + folder = sa_client.get_folder_by_id(folder_id) + sa_client.assign_items( + f"{project['name']}/{folder['name']}", + items=[request.data['name']], + user=random.choice(QA_EMAILS) + ) + + + if __name__ == '__main__': + app.run(host='0.0.0.0', port=5002) + +Interface +---------------- + +.. automethod:: superannotate.SAServer.route +.. automethod:: superannotate.SAServer.add_url_rule +.. automethod:: superannotate.SAServer.run + + +uWSGI +---------- + +`uWSGI`_ is a fast, compiled server suite with extensive configuration +and capabilities beyond a basic server. + +* It can be very performant due to being a compiled program. +* It is complex to configure beyond the basic application, and has so + many options that it can be difficult for beginners to understand. +* It does not support Windows (but does run on WSL). +* It requires a compiler to install in some cases. + +This page outlines the basics of running uWSGI. Be sure to read its +documentation to understand what features are available. + +.. _uWSGI: https://uwsgi-docs.readthedocs.io/en/latest/ + +uWSGI has multiple ways to install it. The most straightforward is to +install the ``pyuwsgi`` package, which provides precompiled wheels for +common platforms. However, it does not provide SSL support, which can be +provided with a reverse proxy instead. + +Install ``pyuwsgi``. + +.. code-block:: text + + $ pip install pyuwsgi + +If you have a compiler available, you can install the ``uwsgi`` package +instead. Or install the ``pyuwsgi`` package from sdist instead of wheel. +Either method will include SSL support. + +.. code-block:: text + + $ pip install uwsgi + + # or + $ pip install --no-binary pyuwsgi pyuwsgi + + +Running +------- + +The most basic way to run uWSGI is to tell it to start an HTTP server +and import your application. + +.. code-block:: text + + $ uwsgi --http 127.0.0.1:8000 --master -p 4 -w wsgi:app + + *** Starting uWSGI 2.0.20 (64bit) on [x] *** + *** Operational MODE: preforking *** + spawned uWSGI master process (pid: x) + spawned uWSGI worker 1 (pid: x, cores: 1) + spawned uWSGI worker 2 (pid: x, cores: 1) + spawned uWSGI worker 3 (pid: x, cores: 1) + spawned uWSGI worker 4 (pid: x, cores: 1) + spawned uWSGI http 1 (pid: x) \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index 66f5fc363..1c2a50922 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 auto --dist=loadscope \ No newline at end of file +;addopts = -n auto --dist=loadscope \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 11136aa72..12e64811f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,5 @@ nest-asyncio==1.5.4 jsonschema==3.2.0 pandas>=1.1.4 aiofiles==0.8.0 +Werkzeug==2.2.2 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 2ca0b520d..bb3a46e28 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -18,6 +18,7 @@ from superannotate.lib.app.input_converters import export_annotation # noqa from superannotate.lib.app.input_converters import import_annotation # noqa from superannotate.lib.app.interface.sdk_interface import SAClient # noqa +from superannotate.lib.app.server import SAServer # noqa from superannotate.lib.core import PACKAGE_VERSION_INFO_MESSAGE # noqa from superannotate.lib.core import PACKAGE_VERSION_MAJOR_UPGRADE # noqa from superannotate.lib.core import PACKAGE_VERSION_UPGRADE # noqa @@ -29,6 +30,7 @@ __all__ = [ "__version__", "SAClient", + "SAServer", # Utils "enums", "AppException", diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index 410e3c506..db1a18d75 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -1,9 +1,12 @@ import os +import shutil import sys import tempfile +from pathlib import Path from typing import Any from typing import Optional +import lib as sa_lib import lib.core as constances from lib import __file__ as lib_path from lib.app.input_converters.conversion import import_annotation @@ -255,3 +258,16 @@ def upload_videos( image_quality_in_editor=None, ) sys.exit(0) + + def create_server(self, name: str, path: str = None): + """ + This will create a directory by the given name in your current or provided directory. + """ + path = Path(os.path.expanduser(path if path else ".")) / name + + if path.exists(): + raise Exception(f"Directory already exists {str(path.absolute())}") + path.mkdir(parents=True) + default_files_path = Path(sa_lib.__file__).parent / "app" / "server" + shutil.copy(default_files_path / "__app.py", path / "app.py") + shutil.copy(default_files_path / "__wsgi.py", path / "wsgi.py") diff --git a/src/superannotate/lib/app/server/__app.py b/src/superannotate/lib/app/server/__app.py new file mode 100644 index 000000000..17aef319e --- /dev/null +++ b/src/superannotate/lib/app/server/__app.py @@ -0,0 +1,12 @@ +from superannotate import SAServer + +app = SAServer() + + +@app.route("/", methods=["POST"]) +def index(request): + return "Hello, World!" + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5002) diff --git a/src/superannotate/lib/app/server/__init__.py b/src/superannotate/lib/app/server/__init__.py new file mode 100644 index 000000000..04ab4b9a9 --- /dev/null +++ b/src/superannotate/lib/app/server/__init__.py @@ -0,0 +1,4 @@ +from lib.app.server.core import SAServer + + +__all__ = ["SAServer"] diff --git a/src/superannotate/lib/app/server/__wsgi.py b/src/superannotate/lib/app/server/__wsgi.py new file mode 100644 index 000000000..e766c0db6 --- /dev/null +++ b/src/superannotate/lib/app/server/__wsgi.py @@ -0,0 +1,16 @@ +from importlib import import_module + +from superannotate import SAServer + + +APPS = ["app"] + + +def create_app(): + server = SAServer() + for path in APPS: + import_module(path) + return server + + +app = create_app() diff --git a/src/superannotate/lib/app/server/core.py b/src/superannotate/lib/app/server/core.py new file mode 100644 index 000000000..2b5849eb5 --- /dev/null +++ b/src/superannotate/lib/app/server/core.py @@ -0,0 +1,199 @@ +import json +import typing + +from werkzeug.exceptions import HTTPException +from werkzeug.routing import Map +from werkzeug.routing import Rule +from werkzeug.serving import run_simple +from werkzeug.wrappers import Request +from werkzeug.wrappers import Response + + +class SingletonMeta(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class SAServer(metaclass=SingletonMeta): + def __init__(self): + self._url_map: Map = Map([]) + self._view_function_map: typing.Dict[str, typing.Callable] = {} + + def route(self, rule: str, methods: typing.List[str] = None, **options: typing.Any): + """Decorate a view function to register it with the given URL + rule and options. Calls :meth:`add_url_rule`, which has more + details about the implementation. + + .. code-block:: python + + @route("/") + def index(): + return "Hello, World!" + + The endpoint name for the route defaults to the name of the view + function if the ``endpoint`` parameter isn't passed. + + The ``methods`` parameter defaults to ``["GET"]``. ``HEAD`` and + ``OPTIONS`` are added automatically. + + :param rule: The URL rule string. + :type rule: str. + + :param methods: Allowed HTTP methods. + :type rule: list of str + + :param options: Extra options passed to the + :class:`~werkzeug.routing.Rule` object. + :type options: list of any + + + """ + + def decorator(f): + endpoint = options.pop("endpoint", None) + options["methods"] = methods + if not endpoint: + endpoint = f.__name__ + self.add_url_rule(rule, endpoint, f, **options) + + return decorator + + def add_url_rule( + self, + rule: str, + endpoint: str = None, + view_func: typing.Callable = None, + **options: typing.Any + ): + """ + Register a rule for routing incoming requests and building + URLs. The :meth:`route` decorator is a shortcut to call this + with the ``view_func`` argument. These are equivalent: + + .. code-block:: python + + @app.route("/") + def index(): + ... + + .. code-block:: python + + def index(): + ... + + app.add_url_rule("/", view_func=index) + + See :ref:`url-route-registrations`. + + The endpoint name for the route defaults to the name of the view + function if the ``endpoint`` parameter isn't passed. An error + will be raised if a function has already been registered for the + endpoint. + + The ``methods`` parameter defaults to ``["GET"]``. ``HEAD`` is + always added automatically, and ``OPTIONS`` is added + automatically by default. + + ``view_func`` does not necessarily need to be passed, but if the + rule should participate in routing an endpoint name must be + associated with a view function at some point with the + :meth:`endpoint` decorator. + + .. code-block:: python + + app.add_url_rule("/", endpoint="index") + + @app.endpoint("index") + def index(): + ... + + If ``view_func`` has a ``required_methods`` attribute, those + methods are added to the passed and automatic methods. If it + has a ``provide_automatic_methods`` attribute, it is used as the + default if the parameter is not passed. + :param rule: The URL rule string. + :type rule: str + + :param endpoint: Endpoint name. + :type endpoint: str + + :param view_func: Handler function. + :type view_func: typing.Callable + + :param options: Extra options passed to the + :class:`~werkzeug.routing.Rule` object. + :type options: list of any + """ + self._url_map.add(Rule(rule, endpoint=endpoint, **options)) + self._view_function_map[endpoint] = view_func + + def _dispatch_request(self, request): + """Dispatches the request.""" + adapter = self._url_map.bind_to_environ(request.environ) + try: + endpoint, values = adapter.match() + view_func = self._view_function_map.get(endpoint) + if not view_func: + return Response(status=404) + response = view_func(request, **values) + if isinstance(response, dict): + return Response(json.dumps(response), content_type="application/json") + return Response(response) + except HTTPException as e: + return e + + def wsgi_app(self, environ, start_response): + """WSGI application that processes requests and returns responses.""" + request = Request(environ) + response = self._dispatch_request(request) + return response(environ, start_response) + + def __call__(self, environ, start_response): + """The WSGI server calls this method as the WSGI application.""" + return self.wsgi_app(environ, start_response) + + def run( + self, + host: str = "localhost", + port: int = 8000, + use_debugger=True, + use_reloader=True, + ssl_context=None, + **kwargs + ): + """Start a development server for a WSGI application. + + .. warning:: + + Do not use the development server when deploying to production. + It is intended for use only during local development. It is not + designed to be particularly efficient, stable, or secure. + + :param host: The host to bind to, for example ``'localhost'``. + Can be a domain, IPv4 or IPv6 address, or file path starting + with ``unix://`` for a Unix socket. + :param port: The port to bind to, for example ``8080``. Using ``0`` + tells the OS to pick a random free port. + :param use_reloader: Use a reloader process to restart the server + process when files are changed. + :param use_debugger: Use Werkzeug's debugger, which will show + formatted tracebacks on unhandled exceptions. + :param ssl_context: Configure TLS to serve over HTTPS. Can be an + :class:`ssl.SSLContext` object, a ``(cert_file, key_file)`` + tuple to create a typical context, or the string ``'adhoc'`` to + generate a temporary self-signed certificate. + """ + run_simple( + host, + port, + self, + use_debugger=use_debugger, + use_reloader=use_reloader, + ssl_context=ssl_context, + **kwargs + ) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index f43f27000..2f84350fb 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -166,3 +166,10 @@ 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))) + + def test_create_server(self): + with tempfile.TemporaryDirectory() as temp_dir: + self._cli.create_server('test', temp_dir) + # self._cli.create_server('testo', '/Users/vaghinak.basentsyan/www/for_fun') + assert (Path(temp_dir) / 'test' / 'app.py').exists() + assert (Path(temp_dir) / 'test' / 'wsgi.py').exists() From 5e046dc43978e28f49ae30f81976e175632152e6 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 3 Jan 2023 16:49:06 +0400 Subject: [PATCH 06/18] Docstring fix --- docs/source/server.rst | 4 ++-- src/superannotate/lib/app/interface/sdk_interface.py | 2 +- tests/unit/test_usecases.py | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/source/server.rst b/docs/source/server.rst index de491d879..41bd8ff6a 100644 --- a/docs/source/server.rst +++ b/docs/source/server.rst @@ -35,7 +35,7 @@ SuperAnnotate Python SDK allows access to the platform without web browser: ] - @app.route("item_completed", methods=["post"]) + @app.route("item_completed", methods=["POST"]) def index(request): """ Listening webhooks on items completed events form Superannotate automation @@ -43,7 +43,7 @@ SuperAnnotate Python SDK allows access to the platform without web browser: """ project_id, folder_id = request.data['project_id'], request.data['folder_id'] project = sa_client.get_project_by_id(project_id) - folder = sa_client.get_folder_by_id(folder_id) + folder = sa_client.get_folder_by_id(project_id=project_id, folder_id=folder_id) sa_client.assign_items( f"{project['name']}/{folder['name']}", items=[request.data['name']], diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index a37127a75..c327998b0 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -254,7 +254,7 @@ def create_project( :param project_description: the new project's description :type project_description: str - :param project_type: the new project type, Vector or Pixel. + :param project_type: the new project type, Vector, Pixel, Video, Document, Tiled, PointCloud, Other. :type project_type: str :param settings: list of settings objects diff --git a/tests/unit/test_usecases.py b/tests/unit/test_usecases.py index 5c8db613f..8a8eeda8a 100644 --- a/tests/unit/test_usecases.py +++ b/tests/unit/test_usecases.py @@ -30,5 +30,4 @@ def test_validate_should_be_called(self): validate_method.assert_called() def test_validate_should_fill_errors(self): - print(self.use_case.execute().errors) assert len(self.use_case.execute().errors) == 2 From f27e26ee628c874632b6f3b36513076dd1440e44 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 12 Jan 2023 17:00:15 +0400 Subject: [PATCH 07/18] Updated convert_project_type --- requirements.txt | 2 +- src/superannotate/__init__.py | 11 ++- .../lib/app/input_converters/sa_conversion.py | 63 +++++++++--------- .../lib/app/interface/cli_interface.py | 1 - src/superannotate/lib/app/server/__init__.py | 3 +- src/superannotate/lib/app/server/__wsgi.py | 15 +---- src/superannotate/lib/app/server/core.py | 63 ++++++++++++++++-- .../lib/app/server/default_app.py | 54 +++++++++++++++ .../lib/app/server/templates/monitor.html | 42 ++++++++++++ src/superannotate/lib/app/server/utils.py | 8 +++ src/superannotate/lib/core/__init__.py | 2 +- src/superannotate/lib/core/service_types.py | 62 ++++------------- .../lib/core/serviceproviders.py | 4 +- .../lib/core/usecases/annotations.py | 1 - src/superannotate/lib/core/usecases/images.py | 4 ++ .../lib/core/usecases/integrations.py | 4 +- src/superannotate/lib/core/usecases/items.py | 16 +++-- .../lib/infrastructure/serviceprovider.py | 17 +++-- .../infrastructure/services/http_client.py | 58 ++++++++++------ .../infrastructure/services/integration.py | 5 +- src/superannotate/logger.py | 33 ++++++++- tests/data_set/pixel_with_holes/1.jpg | Bin 0 -> 51490 bytes .../pixel_with_holes/1.jpg___fuse.png | Bin 0 -> 3264 bytes .../pixel_with_holes/1.jpg___pixel.json | 1 + .../pixel_with_holes/1.jpg___save.png | Bin 0 -> 8862 bytes tests/data_set/pixel_with_holes/2.webp | Bin 0 -> 39076 bytes .../pixel_with_holes/2.webp___pixel.json | 1 + .../pixel_with_holes/2.webp___save.png | Bin 0 -> 42223 bytes .../pixel_with_holes/classes/classes.json | 12 ++++ .../integrations/test_get_integrations.py | 2 +- .../integration/test_convert_project_type.py | 33 +++++++++ 31 files changed, 368 insertions(+), 149 deletions(-) create mode 100644 src/superannotate/lib/app/server/default_app.py create mode 100644 src/superannotate/lib/app/server/templates/monitor.html create mode 100644 src/superannotate/lib/app/server/utils.py create mode 100644 tests/data_set/pixel_with_holes/1.jpg create mode 100644 tests/data_set/pixel_with_holes/1.jpg___fuse.png create mode 100644 tests/data_set/pixel_with_holes/1.jpg___pixel.json create mode 100644 tests/data_set/pixel_with_holes/1.jpg___save.png create mode 100644 tests/data_set/pixel_with_holes/2.webp create mode 100644 tests/data_set/pixel_with_holes/2.webp___pixel.json create mode 100644 tests/data_set/pixel_with_holes/2.webp___save.png create mode 100644 tests/data_set/pixel_with_holes/classes/classes.json create mode 100644 tests/integration/test_convert_project_type.py diff --git a/requirements.txt b/requirements.txt index 12e64811f..9316721d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ ffmpeg-python>=0.2.0 fire==0.4.0 mixpanel==4.8.3 pydantic>=1.10.2 -setuptools~=57.4.0 +setuptools>=57.4.0 aiohttp==3.8.1 email-validator>=1.0.3 nest-asyncio==1.5.4 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index bb3a46e28..cbe14759c 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -1,6 +1,6 @@ import os import sys - +import typing __version__ = "4.4.8" @@ -19,6 +19,7 @@ from superannotate.lib.app.input_converters import import_annotation # noqa from superannotate.lib.app.interface.sdk_interface import SAClient # noqa from superannotate.lib.app.server import SAServer # noqa +from superannotate.lib.app.server.utils import setup_app # noqa from superannotate.lib.core import PACKAGE_VERSION_INFO_MESSAGE # noqa from superannotate.lib.core import PACKAGE_VERSION_MAJOR_UPGRADE # noqa from superannotate.lib.core import PACKAGE_VERSION_UPGRADE # noqa @@ -27,10 +28,18 @@ SESSIONS = {} + +def create_app(apps: typing.List[str]) -> SAServer: + setup_app(apps) + server = SAServer() + return server + + __all__ = [ "__version__", "SAClient", "SAServer", + "create_app", # Utils "enums", "AppException", diff --git a/src/superannotate/lib/app/input_converters/sa_conversion.py b/src/superannotate/lib/app/input_converters/sa_conversion.py index ff1dac54d..11c7060eb 100644 --- a/src/superannotate/lib/app/input_converters/sa_conversion.py +++ b/src/superannotate/lib/app/input_converters/sa_conversion.py @@ -5,6 +5,7 @@ import numpy as np from lib.app.exceptions import AppException from lib.core import DEPRICATED_DOCUMENT_VIDEO_MESSAGE +from shapely.geometry import Polygon from superannotate.logger import get_default_logger from ..common import blue_color_generator @@ -26,59 +27,57 @@ def from_pixel_to_vector(json_paths, output_dir): mask_name = str(json_path).replace("___pixel.json", "___save.png") img = cv2.imread(mask_name) H, W, _ = img.shape - sa_json = json.load(open(json_path)) instances = sa_json["instances"] + new_instances = [] idx = 0 sa_instances = [] + for instance in instances: if "parts" not in instance.keys(): if "type" in instance.keys() and instance["type"] == "meta": sa_instances.append(instance) continue - parts = instance["parts"] + if len(parts) > 1: + idx += 1 + group_id = idx + else: + group_id = 0 + from collections import defaultdict - polygons = [] for part in parts: color = list(hex_to_rgb(part["color"])) mask = np.zeros((H, W), dtype=np.uint8) mask[np.all((img == color[::-1]), axis=2)] = 255 - contours, _ = cv2.findContours( - mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE - ) - part_polygons = [] - for contour in contours: - segment = contour.flatten().tolist() - if len(segment) > 6: - part_polygons.append(segment) - polygons.append(part_polygons) - - for part_polygons in polygons: - if len(part_polygons) > 1: - idx += 1 - group_id = idx - else: - group_id = 0 - for polygon in part_polygons: + # child contour index hierarchy[0][[i][3] + contours, hierarchy = cv2.findContours( + mask, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE + ) + parent_child_map = defaultdict(list) + for idx, _hierarchy in enumerate(hierarchy[0]): + + if len(contours[idx].flatten().tolist()) <= 6: + continue + if _hierarchy[3] < 0: + parent_child_map[idx] = [] + else: + parent_child_map[_hierarchy[3]].append(idx) + + for outer, inners in parent_child_map.items(): + outer_points = contours[outer].flatten().tolist() + exclude_points = [contours[i].flatten().tolist() for i in inners] temp = instance.copy() - del temp["parts"] + del temp['parts'] temp["pointLabels"] = {} temp["groupId"] = group_id temp["type"] = "polygon" - temp["points"] = polygon - sa_instances.append(temp.copy()) - temp["type"] = "bbox" - temp["points"] = { - "x1": min(polygon[::2]), - "x2": max(polygon[::2]), - "y1": min(polygon[1::2]), - "y2": max(polygon[1::2]), - } - sa_instances.append(temp.copy()) + temp["points"] = outer_points + temp["exclude"] = exclude_points + new_instances.append(temp) - sa_json["instances"] = sa_instances + sa_json['instances'] = new_instances write_to_json(output_dir / file_name, sa_json) img_names.append(file_name.replace("___objects.json", "")) return img_names diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index db1a18d75..cab30b4d7 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -264,7 +264,6 @@ def create_server(self, name: str, path: str = None): This will create a directory by the given name in your current or provided directory. """ path = Path(os.path.expanduser(path if path else ".")) / name - if path.exists(): raise Exception(f"Directory already exists {str(path.absolute())}") path.mkdir(parents=True) diff --git a/src/superannotate/lib/app/server/__init__.py b/src/superannotate/lib/app/server/__init__.py index 04ab4b9a9..af01d3128 100644 --- a/src/superannotate/lib/app/server/__init__.py +++ b/src/superannotate/lib/app/server/__init__.py @@ -1,4 +1,5 @@ +from lib.app.server.core import Response from lib.app.server.core import SAServer -__all__ = ["SAServer"] +__all__ = ["SAServer", "Response"] diff --git a/src/superannotate/lib/app/server/__wsgi.py b/src/superannotate/lib/app/server/__wsgi.py index e766c0db6..bd9ac4fca 100644 --- a/src/superannotate/lib/app/server/__wsgi.py +++ b/src/superannotate/lib/app/server/__wsgi.py @@ -1,16 +1,5 @@ -from importlib import import_module - -from superannotate import SAServer - +from superannotate import create_app APPS = ["app"] - -def create_app(): - server = SAServer() - for path in APPS: - import_module(path) - return server - - -app = create_app() +app = create_app(APPS) diff --git a/src/superannotate/lib/app/server/core.py b/src/superannotate/lib/app/server/core.py index 2b5849eb5..30e8d98df 100644 --- a/src/superannotate/lib/app/server/core.py +++ b/src/superannotate/lib/app/server/core.py @@ -1,12 +1,23 @@ import json +import pathlib import typing +from datetime import datetime +from jinja2 import Environment +from jinja2 import FileSystemLoader +from superannotate.logger import get_server_logger from werkzeug.exceptions import HTTPException from werkzeug.routing import Map from werkzeug.routing import Rule from werkzeug.serving import run_simple from werkzeug.wrappers import Request -from werkzeug.wrappers import Response +from werkzeug.wrappers import Response as BaseResponse + +logger = get_server_logger() + + +class Response(BaseResponse): + ... class SingletonMeta(type): @@ -23,8 +34,14 @@ class SAServer(metaclass=SingletonMeta): def __init__(self): self._url_map: Map = Map([]) self._view_function_map: typing.Dict[str, typing.Callable] = {} + self.jinja_env = Environment( + loader=FileSystemLoader(str(pathlib.Path(__file__).parent / "templates")), + autoescape=True, + ) - def route(self, rule: str, methods: typing.List[str] = None, **options: typing.Any): + def route( + self, rule: str, methods: typing.List[str] = None, **options: typing.Any + ) -> typing.Any: """Decorate a view function to register it with the given URL rule and options. Calls :meth:`add_url_rule`, which has more details about the implementation. @@ -135,17 +152,47 @@ def index(): def _dispatch_request(self, request): """Dispatches the request.""" adapter = self._url_map.bind_to_environ(request.environ) + response = None + content = None try: endpoint, values = adapter.match() view_func = self._view_function_map.get(endpoint) if not view_func: return Response(status=404) - response = view_func(request, **values) - if isinstance(response, dict): - return Response(json.dumps(response), content_type="application/json") - return Response(response) + content = view_func(request, **values) + if isinstance(content, Response): + response = content + elif isinstance(content, (list, dict)): + response = Response( + json.dumps(content), content_type="application/json" + ) + else: + response = Response(content) + return response except HTTPException as e: return e + finally: + if "monitor" not in request.full_path and "log" not in request.full_path: + data = { + "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "request": { + "method": request.full_path, + "path": request.url, + "headers": dict(request.headers.items()), + "data": request.data.decode("utf-8"), + }, + "response": { + "headers": dict(request.headers.items()) if response else None, + # 'data': response.data.decode('utf-8') if response else None, + "data": content.data.decode("utf-8") + if isinstance(content, Response) + else content, + "status_code": response.status_code if response else None, + }, + } + # import ndjson + print(11111, request.full_path) + logger.info(json.dumps(data)) def wsgi_app(self, environ, start_response): """WSGI application that processes requests and returns responses.""" @@ -197,3 +244,7 @@ def run( ssl_context=ssl_context, **kwargs ) + + def render_template(self, template_name, **context): + t = self.jinja_env.get_template(template_name) + return Response(t.render(context), mimetype="text/html") diff --git a/src/superannotate/lib/app/server/default_app.py b/src/superannotate/lib/app/server/default_app.py new file mode 100644 index 000000000..0ea767357 --- /dev/null +++ b/src/superannotate/lib/app/server/default_app.py @@ -0,0 +1,54 @@ +import json + +from lib.app.server import SAServer +from lib.core import LOG_FILE_LOCATION + +app = SAServer() + +LOG_FILE = "/var/log/orchestra/consumer.log" + + +@app.route("/monitor", methods=["GET"]) +def monitor_view(request): + return app.render_template("monitor.html", **{}) + + +@app.route("/logs", methods=["GET"]) +def logs(request): + data = [] + limit = 20 + items = [] + cursor = None + get_cursor = lambda x: max(x - 2048, 0) + + with open( + f"{LOG_FILE_LOCATION}/sa_server.log", + ) as log_file: + log_file.seek(0, 2) + file_size = log_file.tell() + cursor = get_cursor(file_size) + while True: + log_file.seek(cursor, 0) + lines = log_file.read().splitlines()[-limit:] + # if cursor == 0 and len(lines) >= limit: + # continue + for line in lines: + try: + items.append(json.loads(line)) + except Exception: + ... + if len(lines) >= limit or cursor == 0: + return items + cursor = get_cursor(cursor) + items = [] + + +# +# @app.route("/_log_stream", methods=["GET"]) +# def log_stream(request): +# def generate(): +# for line in Pygtail(LOG_FILE, every_n=1): +# yield "data:" + str(line) + "\n\n" +# time.sleep(0.5) +# +# return Response(generate(), mimetype="text/event-stream") diff --git a/src/superannotate/lib/app/server/templates/monitor.html b/src/superannotate/lib/app/server/templates/monitor.html new file mode 100644 index 000000000..72cd8e302 --- /dev/null +++ b/src/superannotate/lib/app/server/templates/monitor.html @@ -0,0 +1,42 @@ + + + + + + flasktest + + + + + +
+
+
+
+

SuperServer

+

Progress example

+
+
+
+
+
+
+
+
+
    +
+
+
+
+
+
+ + + + diff --git a/src/superannotate/lib/app/server/utils.py b/src/superannotate/lib/app/server/utils.py new file mode 100644 index 000000000..0ba2468db --- /dev/null +++ b/src/superannotate/lib/app/server/utils.py @@ -0,0 +1,8 @@ +import typing +from importlib import import_module + + +def setup_app(apps: typing.List[str]): + apps.extend(["superannotate.lib.app.server.default_app"]) + for path in apps: + import_module(path) diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 7011fe69d..809f75ff1 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -15,7 +15,7 @@ CONFIG_PATH = "~/.superannotate/config.json" CONFIG_FILE_LOCATION = expanduser(CONFIG_PATH) -LOG_FILE_LOCATION = expanduser("~/.superannotate/sa.log") +LOG_FILE_LOCATION = expanduser("~/.superannotate") BACKEND_URL = "https://api.annotate.online" DEFAULT_IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "tif", "tiff", "webp", "bmp"] diff --git a/src/superannotate/lib/core/service_types.py b/src/superannotate/lib/core/service_types.py index c766c1654..11f981776 100644 --- a/src/superannotate/lib/core/service_types.py +++ b/src/superannotate/lib/core/service_types.py @@ -1,5 +1,4 @@ from typing import Any -from typing import Callable from typing import Dict from typing import List from typing import Optional @@ -9,7 +8,6 @@ from pydantic import BaseModel from pydantic import Extra from pydantic import Field -from pydantic import parse_obj_as class Limit(BaseModel): @@ -91,53 +89,14 @@ class UploadCustomFieldValues(BaseModel): class ServiceResponse(BaseModel): status: Optional[int] reason: Optional[str] - content: Optional[Union[bytes, str]] - data: Optional[Any] + content: Optional[Union[bytes, str]] = None + data: Optional[Any] = None count: Optional[int] = 0 _error: Optional[str] = None class Config: extra = Extra.allow - def __init__( - self, response=None, content_type=None, dispatcher: Callable = None, data=None - ): - if response is None: - super().__init__(data=data, status=200) - return - data = { - "status": response.status_code, - "reason": response.reason, - "content": response.content, - } - try: - response_json = response.json() - except Exception: - response_json = dict() - if not response.ok: - error = response_json.get("error") - if not error: - error = response_json.get("errors", "Unknown Error") - data["_error"] = error - super().__init__(**data) - return - if dispatcher: - _data = response_json - response_json = dispatcher(_data) - data.update(_data) - try: - if isinstance(response_json, dict): - data["count"] = response_json.get("count", None) - - if content_type and content_type is not self.__class__: - data["data"] = parse_obj_as(content_type, response_json) - else: - data["data"] = response_json - except Exception: - data["data"] = {} - - super().__init__(**data) - @property def status_code(self): return self.status @@ -152,17 +111,16 @@ def ok(self): def error(self): if self._error: return self._error - default_message = self.reason if self.reason else "Unknown Error" - if isinstance(self.data, dict) and "error" in self.data: - return self.data.get("error", default_message) - else: - return getattr(self.data, "error", default_message) + return self.data def set_error(self, value: Union[dict, str]): if isinstance(value, dict) and "error" in value: self._error = value["error"] self._error = value + def __str__(self): + return f"Status: {self.status_code}, Error {self.error}" + class ImageResponse(ServiceResponse): data: entities.ImageEntity = None @@ -196,8 +154,12 @@ class ModelListResponse(ServiceResponse): data: List[entities.AnnotationClassEntity] = None -class IntegrationResponse(ServiceResponse): - data: List[entities.IntegrationEntity] = None +class _IntegrationResponse(ServiceResponse): + integrations: List[entities.IntegrationEntity] = [] + + +class IntegrationListResponse(ServiceResponse): + data: _IntegrationResponse class AnnotationClassListResponse(ServiceResponse): diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index f7ac2ce80..b90042a0d 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -13,7 +13,7 @@ from lib.core.service_types import DownloadMLModelAuthDataResponse from lib.core.service_types import FolderListResponse from lib.core.service_types import FolderResponse -from lib.core.service_types import IntegrationResponse +from lib.core.service_types import IntegrationListResponse from lib.core.service_types import ItemListResponse from lib.core.service_types import ModelListResponse from lib.core.service_types import ProjectListResponse @@ -466,7 +466,7 @@ def list(self, condition: Condition = None) -> ModelListResponse: class BaseIntegrationService(SuperannotateServiceProvider): @abstractmethod - def list(self) -> IntegrationResponse: + def list(self) -> IntegrationListResponse: raise NotImplementedError @abstractmethod diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 433795988..8b9cc4fda 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -561,7 +561,6 @@ def chunks(data, size: int = 10000): @staticmethod def extract_name(value: Path): - if constants.VECTOR_ANNOTATION_POSTFIX in value.name: path = value.name.replace(constants.VECTOR_ANNOTATION_POSTFIX, "") elif constants.PIXEL_ANNOTATION_POSTFIX in value.name: diff --git a/src/superannotate/lib/core/usecases/images.py b/src/superannotate/lib/core/usecases/images.py index 81ae652c8..1a00f615b 100644 --- a/src/superannotate/lib/core/usecases/images.py +++ b/src/superannotate/lib/core/usecases/images.py @@ -658,6 +658,10 @@ def validate_project_type(self): raise AppValidationException( constances.LIMITED_FUNCTIONS[self._project.type] ) + if self._project.upload_state == constances.UploadState.EXTERNAL.value: + raise AppValidationException( + "The feature does not support projects containing attached URLs." + ) def validate_variant_type(self): if self._image_variant not in ["original", "lores"]: diff --git a/src/superannotate/lib/core/usecases/integrations.py b/src/superannotate/lib/core/usecases/integrations.py index f175a2aff..da60e2ff5 100644 --- a/src/superannotate/lib/core/usecases/integrations.py +++ b/src/superannotate/lib/core/usecases/integrations.py @@ -17,7 +17,7 @@ def __init__(self, reporter: Reporter, service_provider: BaseServiceProvider): self._service_provider = service_provider def execute(self) -> Response: - integrations = self._service_provider.integrations.list().data + integrations = self._service_provider.integrations.list().data.integrations integrations = list(sorted(integrations, key=lambda x: x.createdAt)) integrations.reverse() self._response.data = integrations @@ -49,7 +49,7 @@ def _upload_path(self): def execute(self) -> Response: integrations: List[ IntegrationEntity - ] = self._service_provider.integrations.list().data + ] = self._service_provider.integrations.list().data.integrations integration_name_lower = self._integration.name.lower() integration = next( (i for i in integrations if i.name.lower() == integration_name_lower), None diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 483749ddf..6091d50d3 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -182,6 +182,9 @@ def execute(self) -> Response: (_sub for _sub in response.data if _sub.name == self._subset), None, ) + else: + self._response.errors = response.error + return self._response if not subset: self._response.errors = AppException( "Subset not found. Use the superannotate." @@ -840,13 +843,13 @@ def __separate_to_paths( # so that we don't query them later. # Otherwise include folder in path object in order to later run a query - removeables = [] + removables = [] for path, value in self.path_separated.items(): project, folder = extract_project_folder(path) if project != self.project.name: - removeables.append(path) + removables.append(path) continue # If no folder was provided in the path use "root" @@ -872,13 +875,13 @@ def __separate_to_paths( break # If the folder did not exist add to skipped if not folder_found: - removeables.append(path) + removables.append(path) except Exception as e: - removeables.append(path) + removables.append(path) # Removing completely incorrect paths and their items - for item in removeables: + for item in removables: self.results["skipped"].extend(self.path_separated[item]["items"]) self.path_separated.pop(item) @@ -970,6 +973,7 @@ def execute( ids = future.result() self.item_ids.extend(ids) except Exception: + raise logger.debug(traceback.format_exc()) subsets = self._service_provider.subsets.list(self.project).data @@ -1016,7 +1020,7 @@ def execute( for path, value in self.path_separated.items(): for item in value: item_id = item.pop( - "id" + "id", None ) # Need to remove it, since its added artificially self.__distribute_to_results(item_id, response, item) diff --git a/src/superannotate/lib/infrastructure/serviceprovider.py b/src/superannotate/lib/infrastructure/serviceprovider.py index cb04a2257..7e000488b 100644 --- a/src/superannotate/lib/infrastructure/serviceprovider.py +++ b/src/superannotate/lib/infrastructure/serviceprovider.py @@ -2,7 +2,6 @@ from typing import List import lib.core as constants -import requests from lib.core import entities from lib.core.conditions import Condition from lib.core.service_types import DownloadMLModelAuthDataResponse @@ -253,7 +252,7 @@ def saqul_query( if query: data["query"] = query items = [] - response = requests.Response() + response = None for _ in range(0, self.MAX_ITEMS_COUNT, self.SAQUL_CHUNK_SIZE): response = self.client.request( self.URL_SAQUL_QUERY, "post", params=params, data=data @@ -263,8 +262,14 @@ def saqul_query( response_items = response.data items.extend(response_items) if len(response_items) < self.SAQUL_CHUNK_SIZE: - service_response = ServiceResponse(response) - service_response.data = items - return service_response + break data["image_index"] += self.SAQUL_CHUNK_SIZE - return ServiceResponse(response) + + if response: + response = ServiceResponse(status=response.status_code, data=items) + if not response.ok: + response.set_error(response.error) + response = ServiceResponse(status=response.status_code, data=items) + else: + response = ServiceResponse(status=200, data=[]) + return response diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index e6cfc9f10..6869c5da3 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -6,7 +6,6 @@ from contextlib import contextmanager from functools import lru_cache from typing import Any -from typing import Callable from typing import Dict from typing import List @@ -20,7 +19,6 @@ from superannotate import __version__ from superannotate.logger import get_default_logger - logger = get_default_logger() @@ -84,6 +82,7 @@ def safe_api(): return safe_api def _request(self, url, method, session, retried=0, **kwargs): + with self.safe_api(): req = requests.Request( method=method, @@ -117,13 +116,12 @@ def request( headers=None, params=None, retried=0, - item_type=None, content_type=ServiceResponse, files=None, - dispatcher: Callable = None, + dispatcher: str = None, ) -> ServiceResponse: - kwargs = {"params": {"team_id": self.team_id}} _url = self._get_url(url) + kwargs = {"params": {"team_id": self.team_id}} if data: kwargs["data"] = json.dumps(data, cls=PydanticEncoder) if params: @@ -135,7 +133,7 @@ def request( response = self._request(_url, method, session=session, retried=0, **kwargs) if files: session.headers.update(self.default_headers) - return content_type(response, dispatcher=dispatcher) + return self.serialize_response(response, content_type, dispatcher) def paginate( self, @@ -143,7 +141,6 @@ def paginate( item_type: Any = None, chunk_size: int = 2000, query_params: Dict[str, Any] = None, - dispatcher: str = "data", ) -> ServiceResponse: offset = 0 total = [] @@ -152,29 +149,24 @@ def paginate( while True: _url = f"{url}{splitter}offset={offset}" _response = self.request( - _url, - method="get", - item_type=List[item_type], - params=query_params, + _url, method="get", params=query_params, dispatcher="data" ) if _response.ok: - if isinstance(_response.data, dict): - data = _response.data.get(dispatcher) - else: - data = _response.data - if data: - total.extend(data) + if _response.data: + total.extend(_response.data) else: break - data_len = len(data) + data_len = len(_response.data) offset += data_len if data_len < chunk_size or _response.count - offset < 0: break else: break + if item_type: response = ServiceResponse( - data=pydantic.parse_obj_as(List[item_type], total) + status=_response.status, + data=pydantic.parse_obj_as(List[item_type], total), ) else: response = ServiceResponse(data=total) @@ -182,3 +174,31 @@ def paginate( response.set_error(_response.error) response.status = _response.status return response + + @staticmethod + def serialize_response( + response: requests.Response, content_type, dispatcher: str = None + ) -> ServiceResponse: + data = { + "status": response.status_code, + } + try: + data_json = response.json() + if not response.ok: + data["_error"] = data_json.get( + "error", data_json.get("errors", "Unknown Error") + ) + else: + if dispatcher: + if dispatcher in data_json: + data["data"] = data_json.pop(dispatcher) + else: + data["data"] = data_json + data_json = {} + data.update(data_json) + else: + data["data"] = data_json + return content_type(**data) + except json.decoder.JSONDecodeError: + data["reason"] = response.reason + return content_type(**data) diff --git a/src/superannotate/lib/infrastructure/services/integration.py b/src/superannotate/lib/infrastructure/services/integration.py index 4c05b35ca..0b605cab2 100644 --- a/src/superannotate/lib/infrastructure/services/integration.py +++ b/src/superannotate/lib/infrastructure/services/integration.py @@ -1,5 +1,5 @@ from lib.core import entities -from lib.core.service_types import IntegrationResponse +from lib.core.service_types import IntegrationListResponse from lib.core.serviceproviders import BaseIntegrationService @@ -12,8 +12,7 @@ def list(self): res = self.client.request( self.URL_LIST, "get", - content_type=IntegrationResponse, - dispatcher=lambda x: x["integrations"], + content_type=IntegrationListResponse, ) return res diff --git a/src/superannotate/logger.py b/src/superannotate/logger.py index 502f1f6f7..ccbebd535 100644 --- a/src/superannotate/logger.py +++ b/src/superannotate/logger.py @@ -2,7 +2,6 @@ import os from logging import Formatter from logging.handlers import RotatingFileHandler -from os.path import expanduser import superannotate.lib.core as constances @@ -10,6 +9,34 @@ loggers = {} +def get_server_logger(): + global loggers + if loggers.get("sa_server"): + return loggers.get("sa_server") + else: + logger = logging.getLogger("sa_server") + logger.propagate = False + logger.setLevel(logging.INFO) + stream_handler = logging.StreamHandler() + logger.addHandler(stream_handler) + try: + log_file_path = os.path.join(constances.LOG_FILE_LOCATION, "sa_server.log") + open(log_file_path, "w").close() + if os.access(log_file_path, os.W_OK): + file_handler = RotatingFileHandler( + log_file_path, + maxBytes=5 * 1024 * 1024, + backupCount=2, + mode="a", + ) + logger.addHandler(file_handler) + except OSError: + pass + finally: + loggers["sa_server"] = logger + return logger + + def get_default_logger(): global loggers if loggers.get("sa"): @@ -24,11 +51,11 @@ def get_default_logger(): # logger.handlers[0] = stream_handler logger.addHandler(stream_handler) try: - log_file_path = expanduser(constances.LOG_FILE_LOCATION) + log_file_path = os.path.join(constances.LOG_FILE_LOCATION, "sa.log") open(log_file_path, "w").close() if os.access(log_file_path, os.W_OK): file_handler = RotatingFileHandler( - expanduser(constances.LOG_FILE_LOCATION), + log_file_path, maxBytes=5 * 1024 * 1024, backupCount=5, mode="a", diff --git a/tests/data_set/pixel_with_holes/1.jpg b/tests/data_set/pixel_with_holes/1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0ae101bc8d7bda7044b4b39678215f7d1e350a0e GIT binary patch literal 51490 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 I8Lr02^q#ek$CatrUn1=S+Bc@(?wCpeTSkVA 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 literal 0 HcmV?d00001 diff --git a/tests/data_set/pixel_with_holes/2.webp b/tests/data_set/pixel_with_holes/2.webp new file mode 100644 index 0000000000000000000000000000000000000000..a5d966d4f018d81be5478b6136d42a2f69407d3b GIT binary patch literal 39076 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 pX@=j{w&>!70{`^F{4-|#^vM!WFQ3(kPmNZ Date: Thu, 12 Jan 2023 17:09:56 +0400 Subject: [PATCH 08/18] Updated pypi url --- src/superannotate/__init__.py | 2 +- tests/integration/test_convert_project_type.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index cbe14759c..5fc22ad3d 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -62,7 +62,7 @@ def log_version_info(): local_version = parse(__version__) if local_version.is_prerelease: logger.info(PACKAGE_VERSION_INFO_MESSAGE.format(__version__)) - req = requests.get("https://pypi.python.org/pypi/superannotate/json") + req = requests.get("https://pypi.org/pypi/superannotate/json") if req.ok: releases = req.json().get("releases", []) pip_version = parse("0") diff --git a/tests/integration/test_convert_project_type.py b/tests/integration/test_convert_project_type.py index 63045f93a..7b3245931 100644 --- a/tests/integration/test_convert_project_type.py +++ b/tests/integration/test_convert_project_type.py @@ -4,9 +4,7 @@ from pathlib import Path from tests import DATA_SET_PATH - from src.superannotate import convert_project_type - from unittest import TestCase From a0fde3deaea06528aadb0981130444b287394186 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Thu, 12 Jan 2023 17:09:56 +0400 Subject: [PATCH 09/18] Updated pypi url --- .../lib/app/input_converters/sa_conversion.py | 1 - .../lib/app/server/default_app.py | 41 ++-- .../lib/app/server/templates/monitor.html | 197 +++++++++++++++--- .../lib/app/server/templates/monitor_old.html | 42 ++++ 4 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 src/superannotate/lib/app/server/templates/monitor_old.html diff --git a/src/superannotate/lib/app/input_converters/sa_conversion.py b/src/superannotate/lib/app/input_converters/sa_conversion.py index 11c7060eb..ee1d7cef7 100644 --- a/src/superannotate/lib/app/input_converters/sa_conversion.py +++ b/src/superannotate/lib/app/input_converters/sa_conversion.py @@ -5,7 +5,6 @@ import numpy as np from lib.app.exceptions import AppException from lib.core import DEPRICATED_DOCUMENT_VIDEO_MESSAGE -from shapely.geometry import Polygon from superannotate.logger import get_default_logger from ..common import blue_color_generator diff --git a/src/superannotate/lib/app/server/default_app.py b/src/superannotate/lib/app/server/default_app.py index 0ea767357..ec40dfe7e 100644 --- a/src/superannotate/lib/app/server/default_app.py +++ b/src/superannotate/lib/app/server/default_app.py @@ -1,4 +1,5 @@ import json +import linecache from lib.app.server import SAServer from lib.core import LOG_FILE_LOCATION @@ -19,28 +20,38 @@ def logs(request): limit = 20 items = [] cursor = None - get_cursor = lambda x: max(x - 2048, 0) + offset = request.args.get('offset', None) + if offset: + offset = int(offset) + limit = int(request.args.get('limit', 20)) + response = { + 'data': [] + } - with open( - f"{LOG_FILE_LOCATION}/sa_server.log", - ) as log_file: + with open(f"{LOG_FILE_LOCATION}/sa_server.log") as log_file: log_file.seek(0, 2) - file_size = log_file.tell() - cursor = get_cursor(file_size) + if not offset: + offset = log_file.tell() + cursor = max(offset - 2048, 0) while True: log_file.seek(cursor, 0) - lines = log_file.read().splitlines()[-limit:] - # if cursor == 0 and len(lines) >= limit: - # continue - for line in lines: + tmp_cursor = cursor + for line in log_file: + tmp_cursor += len(line) + if tmp_cursor > offset: + cursor = max(cursor - 2048, 0) + break try: - items.append(json.loads(line)) + response['data'].append(json.loads(line)) except Exception: ... - if len(lines) >= limit or cursor == 0: - return items - cursor = get_cursor(cursor) - items = [] + cursor = max(cursor - 2048, 0) + if len(response['data']) >= limit or cursor == 0: + break + response['data'] = [] + response['offset'] = cursor + return response + # diff --git a/src/superannotate/lib/app/server/templates/monitor.html b/src/superannotate/lib/app/server/templates/monitor.html index 72cd8e302..b36afe1a0 100644 --- a/src/superannotate/lib/app/server/templates/monitor.html +++ b/src/superannotate/lib/app/server/templates/monitor.html @@ -1,42 +1,183 @@ - + + - - - flasktest - - - + + + + Document + + + + -

-
-
-
-

SuperServer

-

Progress example

-
+
+
+
+
+
All Requests
+
+
+
-
-
-
-
-
-
    -
+
+
+
+
Request
+ +
+
+
+
+
+
+
+
+
Response
+ +
+
+
+
+
+
+
-
- - + - + + \ No newline at end of file diff --git a/src/superannotate/lib/app/server/templates/monitor_old.html b/src/superannotate/lib/app/server/templates/monitor_old.html new file mode 100644 index 000000000..72cd8e302 --- /dev/null +++ b/src/superannotate/lib/app/server/templates/monitor_old.html @@ -0,0 +1,42 @@ + + + + + + flasktest + + + + + +
+
+
+
+

SuperServer

+

Progress example

+
+
+
+
+
+
+
+
+
    +
+
+
+
+
+
+ + + + From 5a9981f22a7d38283756f11678a12c1f5cc3070a Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 13 Jan 2023 15:19:21 +0400 Subject: [PATCH 10/18] Added monitor page --- .../lib/app/input_converters/sa_conversion.py | 4 +- src/superannotate/lib/app/server/__app.py | 21 ++++- .../lib/app/server/default_app.py | 26 +++---- .../lib/app/server/templates/monitor.html | 76 ++++++++++++------- .../lib/app/server/templates/monitor_old.html | 42 ---------- src/superannotate/lib/core/usecases/images.py | 9 +-- 6 files changed, 82 insertions(+), 96 deletions(-) delete mode 100644 src/superannotate/lib/app/server/templates/monitor_old.html diff --git a/src/superannotate/lib/app/input_converters/sa_conversion.py b/src/superannotate/lib/app/input_converters/sa_conversion.py index ee1d7cef7..05de2b1fe 100644 --- a/src/superannotate/lib/app/input_converters/sa_conversion.py +++ b/src/superannotate/lib/app/input_converters/sa_conversion.py @@ -68,7 +68,7 @@ def from_pixel_to_vector(json_paths, output_dir): outer_points = contours[outer].flatten().tolist() exclude_points = [contours[i].flatten().tolist() for i in inners] temp = instance.copy() - del temp['parts'] + del temp["parts"] temp["pointLabels"] = {} temp["groupId"] = group_id temp["type"] = "polygon" @@ -76,7 +76,7 @@ def from_pixel_to_vector(json_paths, output_dir): temp["exclude"] = exclude_points new_instances.append(temp) - sa_json['instances'] = new_instances + sa_json["instances"] = new_instances write_to_json(output_dir / file_name, sa_json) img_names.append(file_name.replace("___objects.json", "")) return img_names diff --git a/src/superannotate/lib/app/server/__app.py b/src/superannotate/lib/app/server/__app.py index 17aef319e..b40410f93 100644 --- a/src/superannotate/lib/app/server/__app.py +++ b/src/superannotate/lib/app/server/__app.py @@ -1,11 +1,24 @@ -from superannotate import SAServer +from superannotate import create_app +from superannotate import SAClient -app = SAServer() +sa_client = SAClient() +app = create_app([]) -@app.route("/", methods=["POST"]) +@app.route("/", methods=["GET"]) +def health_check(request): + return "Hello World!!!" + + +@app.route("/project_created", methods=["POST"]) def index(request): - return "Hello, World!" + """ + Create default folders when project created. + """ + project_name = request.json["after"]["name"] + sa_client.create_folder(project_name, "default_folder_1") + sa_client.create_folder(project_name, "default_folder_2") + return "Default folders created." if __name__ == "__main__": diff --git a/src/superannotate/lib/app/server/default_app.py b/src/superannotate/lib/app/server/default_app.py index ec40dfe7e..273a29efa 100644 --- a/src/superannotate/lib/app/server/default_app.py +++ b/src/superannotate/lib/app/server/default_app.py @@ -1,5 +1,4 @@ import json -import linecache from lib.app.server import SAServer from lib.core import LOG_FILE_LOCATION @@ -16,23 +15,17 @@ def monitor_view(request): @app.route("/logs", methods=["GET"]) def logs(request): - data = [] - limit = 20 - items = [] - cursor = None - offset = request.args.get('offset', None) + offset = request.args.get("offset", None) if offset: offset = int(offset) - limit = int(request.args.get('limit', 20)) - response = { - 'data': [] - } + limit = int(request.args.get("limit", 20)) + response = {"data": []} with open(f"{LOG_FILE_LOCATION}/sa_server.log") as log_file: log_file.seek(0, 2) if not offset: offset = log_file.tell() - cursor = max(offset - 2048, 0) + cursor = max(offset - 2048, 0) while True: log_file.seek(cursor, 0) tmp_cursor = cursor @@ -42,18 +35,17 @@ def logs(request): cursor = max(cursor - 2048, 0) break try: - response['data'].append(json.loads(line)) - except Exception: + response["data"].append(json.loads(line)) + except Exception as _: ... cursor = max(cursor - 2048, 0) - if len(response['data']) >= limit or cursor == 0: + if len(response["data"]) >= limit or cursor == 0: break - response['data'] = [] - response['offset'] = cursor + response["data"] = [] + response["offset"] = cursor return response - # # @app.route("/_log_stream", methods=["GET"]) # def log_stream(request): diff --git a/src/superannotate/lib/app/server/templates/monitor.html b/src/superannotate/lib/app/server/templates/monitor.html index b36afe1a0..7c3074629 100644 --- a/src/superannotate/lib/app/server/templates/monitor.html +++ b/src/superannotate/lib/app/server/templates/monitor.html @@ -76,7 +76,7 @@
-
+
All Requests
@@ -144,40 +144,64 @@
Response
- \ No newline at end of file + diff --git a/src/superannotate/lib/app/server/templates/monitor_old.html b/src/superannotate/lib/app/server/templates/monitor_old.html deleted file mode 100644 index 72cd8e302..000000000 --- a/src/superannotate/lib/app/server/templates/monitor_old.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - flasktest - - - - - -
-
-
-
-

SuperServer

-

Progress example

-
-
-
-
-
-
-
-
-
    -
-
-
-
-
-
- - - - diff --git a/src/superannotate/lib/core/usecases/images.py b/src/superannotate/lib/core/usecases/images.py index 1a00f615b..9d25a2cbd 100644 --- a/src/superannotate/lib/core/usecases/images.py +++ b/src/superannotate/lib/core/usecases/images.py @@ -654,11 +654,10 @@ def __init__( ) def validate_project_type(self): - if self._project.type in constances.LIMITED_FUNCTIONS: - raise AppValidationException( - constances.LIMITED_FUNCTIONS[self._project.type] - ) - if self._project.upload_state == constances.UploadState.EXTERNAL.value: + if ( + self._project.type in constances.LIMITED_FUNCTIONS + or self._project.upload_state == constances.UploadState.EXTERNAL.value + ): raise AppValidationException( "The feature does not support projects containing attached URLs." ) From 85d1be2e1921078a471d851e6623d2b4f1210aa2 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 13 Jan 2023 15:51:56 +0400 Subject: [PATCH 11/18] Fix server logs --- src/superannotate/lib/app/server/core.py | 49 +++++++++---------- .../lib/app/server/default_app.py | 1 + src/superannotate/logger.py | 1 - 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/superannotate/lib/app/server/core.py b/src/superannotate/lib/app/server/core.py index 30e8d98df..29459d375 100644 --- a/src/superannotate/lib/app/server/core.py +++ b/src/superannotate/lib/app/server/core.py @@ -152,8 +152,6 @@ def index(): def _dispatch_request(self, request): """Dispatches the request.""" adapter = self._url_map.bind_to_environ(request.environ) - response = None - content = None try: endpoint, values = adapter.match() view_func = self._view_function_map.get(endpoint) @@ -171,34 +169,35 @@ def _dispatch_request(self, request): return response except HTTPException as e: return e - finally: - if "monitor" not in request.full_path and "log" not in request.full_path: - data = { - "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "request": { - "method": request.full_path, - "path": request.url, - "headers": dict(request.headers.items()), - "data": request.data.decode("utf-8"), - }, - "response": { - "headers": dict(request.headers.items()) if response else None, - # 'data': response.data.decode('utf-8') if response else None, - "data": content.data.decode("utf-8") - if isinstance(content, Response) - else content, - "status_code": response.status_code if response else None, - }, - } - # import ndjson - print(11111, request.full_path) - logger.info(json.dumps(data)) def wsgi_app(self, environ, start_response): """WSGI application that processes requests and returns responses.""" request = Request(environ) response = self._dispatch_request(request) - return response(environ, start_response) + return_value = response(environ, start_response) + if not any(i in request.full_path for i in ("monitor", "logs")): + data = { + "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "request": { + "method": request.method, + "path": request.url, + "headers": dict(request.headers.items()), + "data": request.data.decode("utf-8"), + }, + "response": { + "headers": dict(response.headers.items()) + if hasattr(response, "headers") + else {}, + "data": response.data.decode("utf-8") + if hasattr(response, "data") + else response.description, + "status_code": response.status_code + if hasattr(response, "status_code") + else response.code, + }, + } + logger.info(json.dumps(data)) + return return_value def __call__(self, environ, start_response): """The WSGI server calls this method as the WSGI application.""" diff --git a/src/superannotate/lib/app/server/default_app.py b/src/superannotate/lib/app/server/default_app.py index 273a29efa..ecceeb13e 100644 --- a/src/superannotate/lib/app/server/default_app.py +++ b/src/superannotate/lib/app/server/default_app.py @@ -43,6 +43,7 @@ def logs(request): break response["data"] = [] response["offset"] = cursor + response["data"].reverse() return response diff --git a/src/superannotate/logger.py b/src/superannotate/logger.py index ccbebd535..fb0288c8e 100644 --- a/src/superannotate/logger.py +++ b/src/superannotate/logger.py @@ -21,7 +21,6 @@ def get_server_logger(): logger.addHandler(stream_handler) try: log_file_path = os.path.join(constances.LOG_FILE_LOCATION, "sa_server.log") - open(log_file_path, "w").close() if os.access(log_file_path, os.W_OK): file_handler = RotatingFileHandler( log_file_path, From 54114d51562d6a0a2230fd8c318772fcc1ec6ae3 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 13 Jan 2023 18:35:10 +0400 Subject: [PATCH 12/18] Added Jinja2==3.0.3 --- requirements.txt | 2 +- .../lib/app/input_converters/sa_conversion.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9316721d6..f3e72efa8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,4 +22,4 @@ jsonschema==3.2.0 pandas>=1.1.4 aiofiles==0.8.0 Werkzeug==2.2.2 - +Jinja2==3.0.3 diff --git a/src/superannotate/lib/app/input_converters/sa_conversion.py b/src/superannotate/lib/app/input_converters/sa_conversion.py index 05de2b1fe..e3115f4b1 100644 --- a/src/superannotate/lib/app/input_converters/sa_conversion.py +++ b/src/superannotate/lib/app/input_converters/sa_conversion.py @@ -1,3 +1,4 @@ +import itertools import json import shutil @@ -20,6 +21,7 @@ def copy_file(src_path, dst_path): def from_pixel_to_vector(json_paths, output_dir): img_names = [] + for json_path in json_paths: file_name = str(json_path.name).replace("___pixel.json", "___objects.json") @@ -29,7 +31,7 @@ def from_pixel_to_vector(json_paths, output_dir): sa_json = json.load(open(json_path)) instances = sa_json["instances"] new_instances = [] - idx = 0 + global_idx = itertools.count() sa_instances = [] for instance in instances: @@ -39,8 +41,7 @@ def from_pixel_to_vector(json_paths, output_dir): continue parts = instance["parts"] if len(parts) > 1: - idx += 1 - group_id = idx + group_id = next(global_idx) else: group_id = 0 from collections import defaultdict From 996952cd176c548d3f265550607bccf4d2b67f72 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Fri, 13 Jan 2023 18:50:24 +0400 Subject: [PATCH 13/18] Updated convert_project_type --- tests/integration/test_convert_project_type.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_convert_project_type.py b/tests/integration/test_convert_project_type.py index 7b3245931..1aef442ae 100644 --- a/tests/integration/test_convert_project_type.py +++ b/tests/integration/test_convert_project_type.py @@ -1,11 +1,11 @@ -import os import json +import os import tempfile from pathlib import Path -from tests import DATA_SET_PATH +from unittest import TestCase from src.superannotate import convert_project_type -from unittest import TestCase +from tests import DATA_SET_PATH class TestConvertProjectType(TestCase): @@ -20,12 +20,10 @@ def folder_path(self): def test_convert_pixel_with_holes_to_vector(self): with tempfile.TemporaryDirectory() as temp_dir: convert_project_type(self.folder_path, temp_dir) + assert len(list(Path(temp_dir).glob("*"))) == 5 annotation_files = [i.name for i in Path(temp_dir).glob("*___objects.json")] assert len(annotation_files) == 2 with open(os.path.join(temp_dir, f"{self.SECOND_IMAGE}___objects.json")) as file: data = json.load(file) assert len(data['instances'][0]['exclude']) == 4 - - - From e8cd45488e4bcc309212f192079b6bbfc05dc658 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Mon, 16 Jan 2023 14:03:42 +0400 Subject: [PATCH 14/18] Server update --- requirements.txt | 2 +- src/superannotate/__init__.py | 4 +- .../lib/app/interface/cli_interface.py | 5 ++ src/superannotate/lib/app/server/Dockerfile | 30 ++++++++++++ src/superannotate/lib/app/server/README.rst | 0 .../lib/app/server/deployment/entrypoint.sh | 46 +++++++++++++++++++ .../lib/app/server/deployment/uwsgi.ini | 3 ++ .../lib/app/server/requirements.txt | 1 + src/superannotate/lib/app/server/run.sh | 24 ++++++++++ src/superannotate/lib/app/server/utils.py | 9 ++-- 10 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 src/superannotate/lib/app/server/Dockerfile create mode 100644 src/superannotate/lib/app/server/README.rst create mode 100644 src/superannotate/lib/app/server/deployment/entrypoint.sh create mode 100644 src/superannotate/lib/app/server/deployment/uwsgi.ini create mode 100644 src/superannotate/lib/app/server/requirements.txt create mode 100755 src/superannotate/lib/app/server/run.sh diff --git a/requirements.txt b/requirements.txt index f3e72efa8..34c7f0295 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ pydicom>=2.0.0 boto3>=1.14.53 requests==2.26.0 requests-toolbelt>=0.9.1 +aiohttp>=3.8.1 tqdm==4.64.0 pillow>=7.2.0 matplotlib>=3.3.1 @@ -15,7 +16,6 @@ fire==0.4.0 mixpanel==4.8.3 pydantic>=1.10.2 setuptools>=57.4.0 -aiohttp==3.8.1 email-validator>=1.0.3 nest-asyncio==1.5.4 jsonschema==3.2.0 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 5fc22ad3d..393bf5fff 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import sys import typing -__version__ = "4.4.8" +__version__ = "4.4.9dev1" sys.path.append(os.path.split(os.path.realpath(__file__))[0]) @@ -29,7 +29,7 @@ SESSIONS = {} -def create_app(apps: typing.List[str]) -> SAServer: +def create_app(apps: typing.List[str] = None) -> SAServer: setup_app(apps) server = SAServer() return server diff --git a/src/superannotate/lib/app/interface/cli_interface.py b/src/superannotate/lib/app/interface/cli_interface.py index cab30b4d7..b4f2de3f6 100644 --- a/src/superannotate/lib/app/interface/cli_interface.py +++ b/src/superannotate/lib/app/interface/cli_interface.py @@ -270,3 +270,8 @@ def create_server(self, name: str, path: str = None): default_files_path = Path(sa_lib.__file__).parent / "app" / "server" shutil.copy(default_files_path / "__app.py", path / "app.py") shutil.copy(default_files_path / "__wsgi.py", path / "wsgi.py") + shutil.copy(default_files_path / "Dockerfile", path / "Dockerfile") + shutil.copy(default_files_path / "requirements.txt", path / "requirements.txt") + shutil.copy(default_files_path / "README.rst", path / "README.rst") + shutil.copy(default_files_path / "run.sh", path / "run.sh") + shutil.copytree(default_files_path / "deployment", path / "deployment") diff --git a/src/superannotate/lib/app/server/Dockerfile b/src/superannotate/lib/app/server/Dockerfile new file mode 100644 index 000000000..6136589ef --- /dev/null +++ b/src/superannotate/lib/app/server/Dockerfile @@ -0,0 +1,30 @@ +FROM tiangolo/uwsgi-nginx:python3.8 + + +# Install requirements +COPY requirements.txt /tmp/requirements.txt +RUN pip install --upgrade pip + +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Add the app +COPY . /app +WORKDIR /app + +# Make /app/* available to be imported by Python globally to better support several use cases like Alembic migrations. +ENV PYTHONPATH=/app + +# Move the base entrypoint to reuse it +RUN mv /entrypoint.sh /uwsgi-nginx-entrypoint.sh + +# Copy the entrypoint that will generate Nginx additional configs +COPY deployment/entrypoint.sh /entrypoint.sh +COPY deployment/uwsgi.ini /uwsgi.ini +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] + +# Run the start script provided by the parent image tiangolo/uwsgi-nginx. +# It will check for an /app/prestart.sh script (e.g. for migrations) +# And then will start Supervisor, which in turn will start Nginx and uWSGI +CMD ["/start.sh"] diff --git a/src/superannotate/lib/app/server/README.rst b/src/superannotate/lib/app/server/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/src/superannotate/lib/app/server/deployment/entrypoint.sh b/src/superannotate/lib/app/server/deployment/entrypoint.sh new file mode 100644 index 000000000..3dddb4b13 --- /dev/null +++ b/src/superannotate/lib/app/server/deployment/entrypoint.sh @@ -0,0 +1,46 @@ +#! /usr/bin/env sh +set -e + +/uwsgi-nginx-entrypoint.sh + +# Get the URL for static files from the environment variable +USE_STATIC_URL=${STATIC_URL:-'/static'} +# Get the absolute path of the static files from the environment variable +USE_STATIC_PATH=${STATIC_PATH:-'/app/static'} +# Get the listen port for Nginx, default to 80 +USE_LISTEN_PORT=${LISTEN_PORT:-80} + +if [ -f /app/nginx.conf ]; then + cp /app/nginx.conf /etc/nginx/nginx.conf +else + content_server='server {\n' + content_server=$content_server" listen ${USE_LISTEN_PORT};\n" + content_server=$content_server' location / {\n' + content_server=$content_server' try_files $uri @app;\n' + content_server=$content_server' }\n' + content_server=$content_server' location @app {\n' + content_server=$content_server' include uwsgi_params;\n' + content_server=$content_server' uwsgi_pass unix:///tmp/uwsgi.sock;\n' + content_server=$content_server' }\n' + content_server=$content_server" location $USE_STATIC_URL {\n" + content_server=$content_server" alias $USE_STATIC_PATH;\n" + content_server=$content_server' }\n' + # If STATIC_INDEX is 1, serve / with /static/index.html directly (or the static URL configured) + if [ "$STATIC_INDEX" = 1 ] ; then + content_server=$content_server' location = / {\n' + content_server=$content_server" index $USE_STATIC_URL/index.html;\n" + content_server=$content_server' }\n' + fi + content_server=$content_server'}\n' + # Save generated server /etc/nginx/conf.d/nginx.conf + printf "$content_server" > /etc/nginx/conf.d/nginx.conf +fi + +# For Alpine: +# Explicitly add installed Python packages and uWSGI Python packages to PYTHONPATH +# Otherwise uWSGI can't import Flask +if [ -n "$ALPINEPYTHON" ] ; then + export PYTHONPATH=$PYTHONPATH:/usr/local/lib/$ALPINEPYTHON/site-packages:/usr/lib/$ALPINEPYTHON/site-packages +fi + +exec "$@" diff --git a/src/superannotate/lib/app/server/deployment/uwsgi.ini b/src/superannotate/lib/app/server/deployment/uwsgi.ini new file mode 100644 index 000000000..e1b76d4fe --- /dev/null +++ b/src/superannotate/lib/app/server/deployment/uwsgi.ini @@ -0,0 +1,3 @@ +[uwsgi] +psqi = wsgi +callable = app diff --git a/src/superannotate/lib/app/server/requirements.txt b/src/superannotate/lib/app/server/requirements.txt new file mode 100644 index 000000000..560cc128c --- /dev/null +++ b/src/superannotate/lib/app/server/requirements.txt @@ -0,0 +1 @@ +superannotate diff --git a/src/superannotate/lib/app/server/run.sh b/src/superannotate/lib/app/server/run.sh new file mode 100755 index 000000000..cce3f972b --- /dev/null +++ b/src/superannotate/lib/app/server/run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +######################################################## + +## Shell Script to Build and Run Docker Image + +######################################################## + + +echo "build the docker image" +sudo docker build . -t sa_server +echo "built docker images and proceeding to delete existing container" +result=$(docker ps -q -f name=sa_server) +if [[ $? -eq 0 ]]; then + echo "Container exists" + sudo docker container rm -f sa_server + echo "Deleted the existing docker container" +else + echo "No such container" +fi +echo "Deploying the updated container" +#sudo docker run -d sa_server -p 80:80 +sudo docker run sa_server +echo "Deploying the container" diff --git a/src/superannotate/lib/app/server/utils.py b/src/superannotate/lib/app/server/utils.py index 0ba2468db..187c1cae7 100644 --- a/src/superannotate/lib/app/server/utils.py +++ b/src/superannotate/lib/app/server/utils.py @@ -2,7 +2,8 @@ from importlib import import_module -def setup_app(apps: typing.List[str]): - apps.extend(["superannotate.lib.app.server.default_app"]) - for path in apps: - import_module(path) +def setup_app(apps: typing.List[str] = None): + if apps: + apps.extend(["superannotate.lib.app.server.default_app"]) + for path in apps: + import_module(path) From 628356afe378cce42f2099327da07610d582d2cb Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 17 Jan 2023 10:29:01 +0400 Subject: [PATCH 15/18] Update version --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 393bf5fff..f7190c547 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import sys import typing -__version__ = "4.4.9dev1" +__version__ = "4.4.9dev2" sys.path.append(os.path.split(os.path.realpath(__file__))[0]) From 32bc13d19de8898b5555ea8de874f031f8d2a608 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Wed, 18 Jan 2023 17:27:18 +0400 Subject: [PATCH 16/18] Added sa_server readme --- .../lib/app/interface/sdk_interface.py | 1 + src/superannotate/lib/app/server/README.rst | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index c327998b0..b74c92a60 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -3020,3 +3020,4 @@ def add_items_to_subset( raise AppException(response.errors) return response.data + diff --git a/src/superannotate/lib/app/server/README.rst b/src/superannotate/lib/app/server/README.rst index e69de29bb..7aef28282 100644 --- a/src/superannotate/lib/app/server/README.rst +++ b/src/superannotate/lib/app/server/README.rst @@ -0,0 +1,13 @@ +================ +Superannotate Server +================ + +Structure +======= + +Usage +----------- + +.. code-block:: bash + + ./run.sh --token= \ No newline at end of file From d163b06e4672c464eb8ff562386d5bd67d75ff99 Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan Date: Tue, 24 Jan 2023 14:31:46 +0400 Subject: [PATCH 17/18] Added approve/disapprove items --- docs/source/superannotate.sdk.rst | 2 + pytest.ini | 2 +- requirements.txt | 2 +- src/superannotate/__init__.py | 2 +- .../lib/app/interface/sdk_interface.py | 46 ++- src/superannotate/lib/app/interface/types.py | 17 + src/superannotate/lib/app/server/README.rst | 2 +- src/superannotate/lib/core/__init__.py | 4 +- src/superannotate/lib/core/enums.py | 18 +- .../lib/core/serviceproviders.py | 10 + .../lib/core/usecases/annotations.py | 14 +- src/superannotate/lib/core/usecases/items.py | 291 +++++++++++------- .../lib/infrastructure/controller.py | 17 + .../infrastructure/services/http_client.py | 11 +- .../lib/infrastructure/services/item.py | 18 ++ .../items/test_set_approval_statuses.py | 94 ++++++ tests/integration/test_benchmark.py | 2 +- 17 files changed, 412 insertions(+), 140 deletions(-) create mode 100644 tests/integration/items/test_set_approval_statuses.py diff --git a/docs/source/superannotate.sdk.rst b/docs/source/superannotate.sdk.rst index 8cecf0c30..8713337cd 100644 --- a/docs/source/superannotate.sdk.rst +++ b/docs/source/superannotate.sdk.rst @@ -81,6 +81,8 @@ ______ .. automethod:: superannotate.SAClient.unassign_items .. automethod:: superannotate.SAClient.get_item_metadata .. automethod:: superannotate.SAClient.set_annotation_statuses +.. automethod:: superannotate.SAClient.set_approval_statuses +.. automethod:: superannotate.SAClient.set_approval ---------- diff --git a/pytest.ini b/pytest.ini index 1c2a50922..46f818c07 100644 --- a/pytest.ini +++ b/pytest.ini @@ -2,5 +2,5 @@ minversion = 3.7 log_cli=true python_files = test_*.py -;pytest_plugins = ['pytest_profiling'] +pytest_plugins = ['pytest_profiling'] ;addopts = -n auto --dist=loadscope \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 34c7f0295..379fc1510 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ xmltodict==0.12.0 opencv-python>=4.4.0.42 wheel==0.35.1 packaging>=20.4 -plotly==4.1.0 +plotly>=4.1.0 ffmpeg-python>=0.2.0 fire==0.4.0 mixpanel==4.8.3 diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index f7190c547..00f71623c 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import sys import typing -__version__ = "4.4.9dev2" +__version__ = "4.4.9dev4" sys.path.append(os.path.split(os.path.realpath(__file__))[0]) diff --git a/src/superannotate/lib/app/interface/sdk_interface.py b/src/superannotate/lib/app/interface/sdk_interface.py index b74c92a60..871e33a12 100644 --- a/src/superannotate/lib/app/interface/sdk_interface.py +++ b/src/superannotate/lib/app/interface/sdk_interface.py @@ -23,6 +23,7 @@ from lib.app.interface.types import AnnotationStatuses from lib.app.interface.types import AnnotationType from lib.app.interface.types import AnnotatorRole +from lib.app.interface.types import ApprovalStatuses from lib.app.interface.types import AttachmentArg from lib.app.interface.types import AttachmentDict from lib.app.interface.types import ClassType @@ -2332,7 +2333,7 @@ def search_items( ♦ “QualityCheck” \n ♦ “Returned” \n ♦ “Completed” \n - ♦ “Skippe + ♦ “Skip” \n :type annotation_status: str :param annotator_email: returns those items’ names that are assigned to the specified annotator. @@ -2569,13 +2570,13 @@ def set_annotation_statuses( :param project: project name or folder path (e.g., “project1/folder1”). :type project: str - :param annotation_status: annotation status to set, should be one of. - “NotStarted” - “InProgress” - “QualityCheck” - “Returned” - “Completed” - “Skipped” + :param annotation_status: annotation status to set, should be one of. \n + ♦ “NotStarted” \n + ♦ “InProgress” \n + ♦ “QualityCheck” \n + ♦ “Returned” \n + ♦ “Completed” \n + ♦ “Skipped” \n :type annotation_status: str :param items: item names to set the mentioned status for. If None, all the items in the project will be used. @@ -3021,3 +3022,32 @@ def add_items_to_subset( return response.data + def set_approval_statuses( + self, + project: NotEmptyStr, + approval_status: Union[ApprovalStatuses, None], + items: Optional[List[NotEmptyStr]] = None, + ): + """Sets annotation statuses of items + + :param project: project name or folder path (e.g., “project1/folder1”). + :type project: str + + :param approval_status: approval status to set, should be one of. \n + ♦ None \n + ♦ “Approved” \n + ♦ “Disapproved” \n + :type approval_status: str + + :param items: item names to set the mentioned status for. If None, all the items in the project will be used. + :type items: list of strs + """ + project, folder = self.controller.get_project_folder_by_path(project) + response = self.controller.items.set_approval_statuses( + project=project, + folder=folder, + approval_status=approval_status, + item_names=items, + ) + if response.errors: + raise AppException(response.errors) diff --git a/src/superannotate/lib/app/interface/types.py b/src/superannotate/lib/app/interface/types.py index cea32d521..fac2c4346 100644 --- a/src/superannotate/lib/app/interface/types.py +++ b/src/superannotate/lib/app/interface/types.py @@ -5,6 +5,7 @@ from typing import Union from lib.core.enums import AnnotationStatus +from lib.core.enums import ApprovalStatus from lib.core.enums import BaseTitledEnum from lib.core.enums import ClassTypeEnum from lib.core.enums import FolderStatus @@ -199,6 +200,22 @@ def validate(cls, value: Union[str]) -> Union[str]: return value +class ApprovalStatuses(StrictStr): + @classmethod + def validate(cls, value: Union[str]) -> Union[str]: + if value is None: + return value + if value.lower() not in ApprovalStatus.values() or not isinstance(value, str): + raise TypeError( + f"Available approval_status options are {', '.join(map(str, ApprovalStatus.titles()))}." + ) + return value + + @classmethod + def __get_validators__(cls): + yield cls.validate + + def validate_arguments(func): @wraps(func) def wrapped(self, *args, **kwargs): diff --git a/src/superannotate/lib/app/server/README.rst b/src/superannotate/lib/app/server/README.rst index 7aef28282..2ade3cf50 100644 --- a/src/superannotate/lib/app/server/README.rst +++ b/src/superannotate/lib/app/server/README.rst @@ -10,4 +10,4 @@ Usage .. code-block:: bash - ./run.sh --token= \ No newline at end of file + ./run.sh --token= diff --git a/src/superannotate/lib/core/__init__.py b/src/superannotate/lib/core/__init__.py index 809f75ff1..c6a095fad 100644 --- a/src/superannotate/lib/core/__init__.py +++ b/src/superannotate/lib/core/__init__.py @@ -2,6 +2,7 @@ from superannotate.lib.core.config import Config from superannotate.lib.core.enums import AnnotationStatus +from superannotate.lib.core.enums import ApprovalStatus from superannotate.lib.core.enums import FolderStatus from superannotate.lib.core.enums import ImageQuality from superannotate.lib.core.enums import ProjectStatus @@ -16,7 +17,7 @@ CONFIG_PATH = "~/.superannotate/config.json" CONFIG_FILE_LOCATION = expanduser(CONFIG_PATH) LOG_FILE_LOCATION = expanduser("~/.superannotate") -BACKEND_URL = "https://api.annotate.online" +BACKEND_URL = "https://api.superannotate.com" DEFAULT_IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "tif", "tiff", "webp", "bmp"] DEFAULT_FILE_EXCLUDE_PATTERNS = ["___save.png", "___fuse.png"] @@ -121,6 +122,7 @@ SegmentationStatus, ImageQuality, AnnotationStatus, + ApprovalStatus, CONFIG_FILE_LOCATION, CONFIG_PATH, BACKEND_URL, diff --git a/src/superannotate/lib/core/enums.py b/src/superannotate/lib/core/enums.py index 782ca678a..d3222966b 100644 --- a/src/superannotate/lib/core/enums.py +++ b/src/superannotate/lib/core/enums.py @@ -12,7 +12,7 @@ def __get__(self, instance, owner): class BaseTitledEnum(int, Enum): def __new__(cls, title, value): - obj = int.__new__(cls, value) + obj = super().__new__(cls, value) obj._value_ = value obj.__doc__ = title obj._type = "titled_enum" @@ -40,7 +40,7 @@ def get_name(cls, value): @classmethod def get_value(cls, name): for enum in list(cls): - if enum.__doc__.lower() == name.lower(): + if enum.__doc__ and name and enum.__doc__.lower() == name.lower(): if isinstance(enum.value, int): if enum.value < 0: return "" @@ -48,7 +48,7 @@ def get_value(cls, name): @classmethod def values(cls): - return [enum.__doc__.lower() for enum in list(cls)] + return [enum.__doc__.lower() if enum else None for enum in list(cls)] @classmethod def titles(cls): @@ -64,6 +64,12 @@ def __hash__(self): return hash(self.name) +class ApprovalStatus(BaseTitledEnum): + NONE = None, 0 + DISAPPROVED = "Disapproved", 1 + APPROVED = "Approved", 2 + + class AnnotationTypes(str, Enum): BBOX = "bbox" EVENT = "event" @@ -170,9 +176,3 @@ class SegmentationStatus(BaseTitledEnum): IN_PROGRESS = "InProgress", 2 COMPLETED = "Completed", 3 FAILED = "Failed", 4 - - -class ApprovalStatus(BaseTitledEnum): - NONE = None, 0 - DISAPPROVED = "disapproved", 1 - APPROVED = "approved", 2 diff --git a/src/superannotate/lib/core/serviceproviders.py b/src/superannotate/lib/core/serviceproviders.py index b90042a0d..fd24f699e 100644 --- a/src/superannotate/lib/core/serviceproviders.py +++ b/src/superannotate/lib/core/serviceproviders.py @@ -293,6 +293,16 @@ def set_statuses( ) -> ServiceResponse: raise NotImplementedError + @abstractmethod + def set_approval_statuses( + self, + project: entities.ProjectEntity, + folder: entities.FolderEntity, + item_names: List[str], + approval_status: int, + ) -> ServiceResponse: + raise NotImplementedError + @abstractmethod def delete_multiple( self, project: entities.ProjectEntity, item_ids: List[int] diff --git a/src/superannotate/lib/core/usecases/annotations.py b/src/superannotate/lib/core/usecases/annotations.py index 8b9cc4fda..b92fb7c47 100644 --- a/src/superannotate/lib/core/usecases/annotations.py +++ b/src/superannotate/lib/core/usecases/annotations.py @@ -53,7 +53,6 @@ BIG_FILE_THRESHOLD = 15 * 1024 * 1024 ANNOTATION_CHUNK_SIZE_MB = 10 * 1024 * 1024 URI_THRESHOLD = 4 * 1024 - 120 -nest_asyncio.apply() @dataclass @@ -376,6 +375,7 @@ def execute(self): len(items_to_upload), description="Uploading Annotations" ) try: + nest_asyncio.apply() asyncio.run(self.run_workers(items_to_upload)) except Exception: logger.debug(traceback.format_exc()) @@ -720,6 +720,7 @@ def execute(self): except KeyError: missing_annotations.append(name) try: + nest_asyncio.apply() asyncio.run(self.run_workers(items_to_upload)) except Exception: logger.debug(traceback.format_exc()) @@ -917,6 +918,7 @@ def execute(self): json.dump(annotation_json, annotation_file) size = annotation_file.tell() annotation_file.seek(0) + nest_asyncio.apply() if size > BIG_FILE_THRESHOLD: uploaded = asyncio.run( self._service_provider.annotations.upload_big_annotation( @@ -1109,6 +1111,7 @@ def execute(self): ) small_annotations = [x["name"] for x in items["small"]] try: + nest_asyncio.apply() annotations = asyncio.run( self.run_workers(items["large"], small_annotations) ) @@ -1355,14 +1358,6 @@ def download_annotation_classes(self, path: str): def get_items_count(path: str): return sum([len(files) for r, d, files in os.walk(path)]) - @staticmethod - def coroutine_wrapper(coroutine): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - count = loop.run_until_complete(coroutine) - loop.close() - return count - async def download_big_annotations(self, queue_idx, export_path): while True: cur_queue = self._big_file_queues[queue_idx] @@ -1463,6 +1458,7 @@ def execute(self): if not folders: folders.append(self._folder) with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: + nest_asyncio.apply() futures = [] for folder in folders: if not self._item_names: diff --git a/src/superannotate/lib/core/usecases/items.py b/src/superannotate/lib/core/usecases/items.py index 6091d50d3..a62ffc1c5 100644 --- a/src/superannotate/lib/core/usecases/items.py +++ b/src/superannotate/lib/core/usecases/items.py @@ -1,15 +1,15 @@ import copy import traceback from collections import defaultdict -from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import as_completed from typing import Dict from typing import List from typing import Optional import superannotate.lib.core as constants -from lib.core.conditions import Condition from lib.core.conditions import CONDITION_EQ as EQ +from lib.core.conditions import Condition from lib.core.entities import AttachmentEntity from lib.core.entities import BaseItemEntity from lib.core.entities import DocumentEntity @@ -43,7 +43,7 @@ def __init__(self, item_id, project, service_provider): super().__init__() def execute( - self, + self, ): try: @@ -65,13 +65,13 @@ def execute( class GetItem(BaseReportableUseCase): def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - folder: FolderEntity, - service_provider: BaseServiceProvider, - item_name: str, - include_custom_metadata: bool, + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + service_provider: BaseServiceProvider, + item_name: str, + include_custom_metadata: bool, ): super().__init__(reporter) self._project = project @@ -82,8 +82,8 @@ def __init__( def validate_project_type(self): if ( - self._project.type == constants.ProjectType.PIXEL.value - and self._include_custom_metadata + self._project.type == constants.ProjectType.PIXEL.value + and self._include_custom_metadata ): raise AppException(constants.METADATA_DEPRICATED_FOR_PIXEL) @@ -109,10 +109,10 @@ def serialize_entity(entity: BaseItemEntity, project: ProjectEntity): def execute(self) -> Response: if self.is_valid(): condition = ( - Condition("name", self._item_name, EQ) - & Condition("project_id", self._project.id, EQ) - & Condition("folder_id", self._folder.id, EQ) - & Condition("includeCustomMetadata", self._include_custom_metadata, EQ) + Condition("name", self._item_name, EQ) + & Condition("project_id", self._project.id, EQ) + & Condition("folder_id", self._folder.id, EQ) + & Condition("includeCustomMetadata", self._include_custom_metadata, EQ) ) response = self._service_provider.items.list(condition) if not response.ok: @@ -130,13 +130,13 @@ def execute(self) -> Response: class QueryEntitiesUseCase(BaseReportableUseCase): def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - folder: FolderEntity, - service_provider: BaseServiceProvider, - query: str, - subset: str = None, + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + service_provider: BaseServiceProvider, + query: str, + subset: str = None, ): super().__init__(reporter) self._project = project @@ -218,13 +218,13 @@ def execute(self) -> Response: class ListItems(BaseUseCase): def __init__( - self, - project: ProjectEntity, - folder: FolderEntity, - service_provider: BaseServiceProvider, - search_condition: Condition, - recursive: bool = False, - include_custom_metadata: bool = False, + self, + project: ProjectEntity, + folder: FolderEntity, + service_provider: BaseServiceProvider, + search_condition: Condition, + recursive: bool = False, + include_custom_metadata: bool = False, ): super().__init__() self._project = project @@ -240,8 +240,8 @@ def validate_recursive_case(self): def validate_project_type(self): if ( - self._project.type == constants.ProjectType.PIXEL.value - and self._include_custom_metadata + self._project.type == constants.ProjectType.PIXEL.value + and self._include_custom_metadata ): raise AppException(constants.METADATA_DEPRICATED_FOR_PIXEL) @@ -288,12 +288,12 @@ class AssignItemsUseCase(BaseUseCase): CHUNK_SIZE = 500 def __init__( - self, - service_provider: BaseServiceProvider, - project: ProjectEntity, - folder: FolderEntity, - item_names: list, - user: str, + self, + service_provider: BaseServiceProvider, + project: ProjectEntity, + folder: FolderEntity, + item_names: list, + user: str, ): super().__init__() self._project = project @@ -303,7 +303,7 @@ def __init__( self._service_provider = service_provider def validate_item_names( - self, + self, ): self._item_names = list(set(self._item_names)) @@ -316,7 +316,7 @@ def execute(self): project=self._project, folder=self._folder, user=self._user, - item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203 + item_names=self._item_names[i: i + self.CHUNK_SIZE], # noqa: E203 ) if not response.ok and response.error: # User not found self._response.errors += response.error @@ -333,11 +333,11 @@ class UnAssignItemsUseCase(BaseUseCase): CHUNK_SIZE = 500 def __init__( - self, - service_provider: BaseServiceProvider, - project: ProjectEntity, - folder: FolderEntity, - item_names: list, + self, + service_provider: BaseServiceProvider, + project: ProjectEntity, + folder: FolderEntity, + item_names: list, ): super().__init__() self._project = project @@ -351,7 +351,7 @@ def execute(self): response = self._service_provider.projects.un_assign_items( project=self._project, folder=self._folder, - item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203 + item_names=self._item_names[i: i + self.CHUNK_SIZE], # noqa: E203 ) if not response.ok: self._response.errors = AppException( @@ -365,14 +365,14 @@ class AttachItems(BaseReportableUseCase): CHUNK_SIZE = 500 def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - folder: FolderEntity, - attachments: List[AttachmentEntity], - annotation_status: str, - service_provider: BaseServiceProvider, - upload_state_code: int = constants.UploadState.EXTERNAL.value, + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + attachments: List[AttachmentEntity], + annotation_status: str, + service_provider: BaseServiceProvider, + upload_state_code: int = constants.UploadState.EXTERNAL.value, ): super().__init__(reporter) self._project = project @@ -403,8 +403,8 @@ def validate_limitations(self): elif attachments_count > response.data.project_limit.remaining_image_count: raise AppValidationException(constants.ATTACH_PROJECT_LIMIT_ERROR_MESSAGE) elif ( - response.data.user_limit - and attachments_count > response.data.user_limit.remaining_image_count + response.data.user_limit + and attachments_count > response.data.user_limit.remaining_image_count ): raise AppValidationException(constants.ATTACH_USER_LIMIT_ERROR_MESSAGE) @@ -422,7 +422,7 @@ def execute(self) -> Response: attached = [] self.reporter.start_progress(self.attachments_count, "Attaching URLs") for i in range(0, self.attachments_count, self.CHUNK_SIZE): - attachments = self._attachments[i : i + self.CHUNK_SIZE] # noqa: E203 + attachments = self._attachments[i: i + self.CHUNK_SIZE] # noqa: E203 response = self._service_provider.items.list_by_names( project=self._project, folder=self._folder, @@ -468,14 +468,14 @@ class CopyItems(BaseReportableUseCase): CHUNK_SIZE = 500 def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - from_folder: FolderEntity, - to_folder: FolderEntity, - item_names: List[str], - service_provider: BaseServiceProvider, - include_annotations: bool, + self, + reporter: Reporter, + project: ProjectEntity, + from_folder: FolderEntity, + to_folder: FolderEntity, + item_names: List[str], + service_provider: BaseServiceProvider, + include_annotations: bool, ): super().__init__(reporter) self._project = project @@ -518,7 +518,7 @@ def execute(self): cand_items = self._service_provider.items.list_by_names( project=self._project, folder=self._to_folder, - names=items[i : i + self.CHUNK_SIZE], # noqa + names=items[i: i + self.CHUNK_SIZE], # noqa ).data if isinstance(cand_items, dict): continue @@ -533,7 +533,7 @@ def execute(self): return self._response if items_to_copy: for i in range(0, len(items_to_copy), self.CHUNK_SIZE): - chunk_to_copy = items_to_copy[i : i + self.CHUNK_SIZE] # noqa: E203 + chunk_to_copy = items_to_copy[i: i + self.CHUNK_SIZE] # noqa: E203 response = self._service_provider.items.copy_multiple( project=self._project, from_folder=self._from_folder, @@ -558,7 +558,7 @@ def execute(self): cand_items = self._service_provider.items.list_by_names( project=self._project, folder=self._to_folder, - names=items[i : i + self.CHUNK_SIZE], # noqa + names=items[i: i + self.CHUNK_SIZE], # noqa ) if isinstance(cand_items, dict): continue @@ -583,13 +583,13 @@ class MoveItems(BaseReportableUseCase): CHUNK_SIZE = 1000 def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - from_folder: FolderEntity, - to_folder: FolderEntity, - item_names: List[str], - service_provider: BaseServiceProvider, + self, + reporter: Reporter, + project: ProjectEntity, + from_folder: FolderEntity, + to_folder: FolderEntity, + item_names: List[str], + service_provider: BaseServiceProvider, ): super().__init__(reporter) self._project = project @@ -637,7 +637,7 @@ def execute(self): project=self._project, from_folder=self._from_folder, to_folder=self._to_folder, - item_names=items[i : i + self.CHUNK_SIZE], # noqa: E203 + item_names=items[i: i + self.CHUNK_SIZE], # noqa: E203 ) if response.ok and response.data.get("done"): moved_images.extend(response.data["done"]) @@ -657,13 +657,13 @@ class SetAnnotationStatues(BaseReportableUseCase): ERROR_MESSAGE = "Failed to change status" def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - folder: FolderEntity, - annotation_status: str, - service_provider: BaseServiceProvider, - item_names: List[str] = None, + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + annotation_status: str, + service_provider: BaseServiceProvider, + item_names: List[str] = None, ): super().__init__(reporter) self._project = project @@ -685,7 +685,7 @@ def validate_items(self): return existing_items = [] for i in range(0, len(self._item_names), self.CHUNK_SIZE): - search_names = self._item_names[i : i + self.CHUNK_SIZE] # noqa + search_names = self._item_names[i: i + self.CHUNK_SIZE] # noqa response = self._service_provider.items.list_by_names( project=self._project, folder=self._folder, @@ -709,7 +709,7 @@ def execute(self): status_changed = self._service_provider.items.set_statuses( project=self._project, folder=self._folder, - item_names=self._item_names[i : i + self.CHUNK_SIZE], # noqa: E203, + item_names=self._item_names[i: i + self.CHUNK_SIZE], # noqa: E203, annotation_status=self._annotation_status_code, ) if not status_changed: @@ -718,15 +718,96 @@ def execute(self): return self._response +class SetApprovalStatues(BaseReportableUseCase): + CHUNK_SIZE = 3000 + ERROR_MESSAGE = "Failed to change approval status." + + def __init__( + self, + reporter: Reporter, + project: ProjectEntity, + folder: FolderEntity, + approval_status: str, + service_provider: BaseServiceProvider, + item_names: List[str] = None, + ): + super().__init__(reporter) + self._project = project + self._folder = folder + self._item_names = item_names + self._approval_status_code = constants.ApprovalStatus.get_value(approval_status) + self._service_provider = service_provider + + def validate_items(self): + if not self._item_names: + condition = Condition("project_id", self._project.id, EQ) & Condition( + "folder_id", self._folder.id, EQ + ) + self._item_names = [ + item.name for item in self._service_provider.items.list(condition).data + ] + return + else: + _tmp = set(self._item_names) + unique, total = len(_tmp), len(self._item_names) + if unique < total: + logger.info( + f"Dropping duplicates. Found {unique}/{total} unique items." + ) + self._item_names = list(_tmp) + existing_items = [] + for i in range(0, len(self._item_names), self.CHUNK_SIZE): + search_names = self._item_names[i: i + self.CHUNK_SIZE] # noqa + response = self._service_provider.items.list_by_names( + project=self._project, + folder=self._folder, + names=search_names, + ) + if not response.ok: + raise AppValidationException(response.error) + cand_items = response.data + existing_items += cand_items + if not existing_items: + raise AppValidationException("No items found.") + if existing_items: + self._item_names = list( + {i.name for i in existing_items}.intersection(set(self._item_names)) + ) + + def execute(self): + if self.is_valid(): + total_items = 0 + for i in range(0, len(self._item_names), self.CHUNK_SIZE): + response = self._service_provider.items.set_approval_statuses( + project=self._project, + folder=self._folder, + item_names=self._item_names[i: i + self.CHUNK_SIZE], # noqa: E203, + approval_status=self._approval_status_code, + ) + if not response.ok: + if response.error == 'Unsupported project type.': + self._response.errors = f"The function is not supported for" \ + f" {constants.ProjectType.get_name(self._project.type)} projects." + else: + self._response.errors = self.ERROR_MESSAGE + return self._response + total_items += len(response.data) + if total_items: + logger.info( + f"Successfully updated {total_items}/{len(self._item_names)} item(s)" + ) + return self._response + + class DeleteItemsUseCase(BaseUseCase): CHUNK_SIZE = 1000 def __init__( - self, - project: ProjectEntity, - folder: FolderEntity, - service_provider: BaseServiceProvider, - item_names: List[str] = None, + self, + project: ProjectEntity, + folder: FolderEntity, + service_provider: BaseServiceProvider, + item_names: List[str] = None, ): super().__init__() self._project = project @@ -757,7 +838,7 @@ def execute(self): for i in range(0, len(item_ids), self.CHUNK_SIZE): self._service_provider.items.delete_multiple( project=self._project, - item_ids=item_ids[i : i + self.CHUNK_SIZE], # noqa: E203 + item_ids=item_ids[i: i + self.CHUNK_SIZE], # noqa: E203 ) logger.info( f"Items deleted in project {self._project.name}{'/' + self._folder.name if not self._folder.is_root else ''}" @@ -770,13 +851,13 @@ class AddItemsToSubsetUseCase(BaseUseCase): CHUNK_SIZE = 5000 def __init__( - self, - reporter: Reporter, - project: ProjectEntity, - subset_name: str, - items: List[dict], - service_provider: BaseServiceProvider, - root_folder: FolderEntity, + self, + reporter: Reporter, + project: ProjectEntity, + subset_name: str, + items: List[dict], + service_provider: BaseServiceProvider, + root_folder: FolderEntity, ): self.reporter = reporter self.project = project @@ -790,7 +871,7 @@ def __init__( super().__init__() def __filter_duplicates( - self, + self, ): def uniqueQ(item, seen): result = True @@ -812,7 +893,7 @@ def uniqueQ(item, seen): return uniques def __filter_invalid_items( - self, + self, ): def validQ(item): if "id" in item: @@ -827,7 +908,7 @@ def validQ(item): return filtered_items def __separate_to_paths( - self, + self, ): for item in self.items: if "id" in item: @@ -940,13 +1021,13 @@ def __distribute_to_results(self, item_id, response, item): self.results["failed"].append(item) def validate_items( - self, + self, ): filtered_items = self.__filter_duplicates() if len(filtered_items) != len(self.items): self.reporter.log_info( - f"Dropping duplicates. Found {len(filtered_items)} / {len(self.items)} unique items" + f"Dropping duplicates. Found {len(filtered_items)} / {len(self.items)} unique items." ) self.items = filtered_items self.items = self.__filter_invalid_items() @@ -958,7 +1039,7 @@ def validate_project(self): raise AppException(response.error) def execute( - self, + self, ): if self.is_valid(): @@ -997,7 +1078,7 @@ def execute( for i in range(0, len(self.item_ids), self.CHUNK_SIZE): tmp_response = self._service_provider.subsets.add_items( project=self.project, - item_ids=self.item_ids[i : i + self.CHUNK_SIZE], # noqa + item_ids=self.item_ids[i: i + self.CHUNK_SIZE], # noqa subset=subset, ) diff --git a/src/superannotate/lib/infrastructure/controller.py b/src/superannotate/lib/infrastructure/controller.py index ecbc7c540..e02edfaca 100644 --- a/src/superannotate/lib/infrastructure/controller.py +++ b/src/superannotate/lib/infrastructure/controller.py @@ -482,6 +482,23 @@ def set_annotation_statuses( ) return use_case.execute() + def set_approval_statuses( + self, + project: ProjectEntity, + folder: FolderEntity, + approval_status: str, + item_names: List[str] = None, + ): + use_case = usecases.SetApprovalStatues( + Reporter(), + project=project, + folder=folder, + approval_status=approval_status, + item_names=item_names, + service_provider=self.service_provider, + ) + return use_case.execute() + def update(self, project: ProjectEntity, item: BaseItemEntity): use_case = usecases.UpdateItemUseCase( project=project, service_provider=self.service_provider, item=item diff --git a/src/superannotate/lib/infrastructure/services/http_client.py b/src/superannotate/lib/infrastructure/services/http_client.py index 6869c5da3..1d0fb9fbe 100644 --- a/src/superannotate/lib/infrastructure/services/http_client.py +++ b/src/superannotate/lib/infrastructure/services/http_client.py @@ -185,9 +185,14 @@ def serialize_response( try: data_json = response.json() if not response.ok: - data["_error"] = data_json.get( - "error", data_json.get("errors", "Unknown Error") - ) + if response.status_code in (502, 504): + data[ + "_error" + ] = "Our service is currently unavailable, please try again later." + else: + data["_error"] = data_json.get( + "error", data_json.get("errors", "Unknown Error") + ) else: if dispatcher: if dispatcher in data_json: diff --git a/src/superannotate/lib/infrastructure/services/item.py b/src/superannotate/lib/infrastructure/services/item.py index 198b848bd..5837701d1 100644 --- a/src/superannotate/lib/infrastructure/services/item.py +++ b/src/superannotate/lib/infrastructure/services/item.py @@ -29,6 +29,7 @@ class ItemService(BaseItemService): URL_COPY_PROGRESS = "images/copy-image-progress" URL_DELETE_ITEMS = "image/delete/images" URL_SET_ANNOTATION_STATUSES = "image/updateAnnotationStatusBulk" + URL_SET_APPROVAL_STATUSES = "/items/bulk/change" URL_GET_BY_ID = "image/{image_id}" PROJECT_TYPE_RESPONSE_MAP = { @@ -200,6 +201,23 @@ def set_statuses( }, ) + def set_approval_statuses( + self, + project: entities.ProjectEntity, + folder: entities.FolderEntity, + item_names: List[str], + approval_status: int, + ): + return self.client.request( + self.URL_SET_APPROVAL_STATUSES, + "post", + params={"project_id": project.id, "folder_id": folder.id}, + data={ + "item_names": item_names, + "change_actions": {"APPROVAL_STATUS": approval_status}, + }, + ) + def delete_multiple(self, project: entities.ProjectEntity, item_ids: List[int]): return self.client.request( self.URL_DELETE_ITEMS, diff --git a/tests/integration/items/test_set_approval_statuses.py b/tests/integration/items/test_set_approval_statuses.py new file mode 100644 index 000000000..adff1ba10 --- /dev/null +++ b/tests/integration/items/test_set_approval_statuses.py @@ -0,0 +1,94 @@ +import os +from pathlib import Path + +from src.superannotate import AppException +from src.superannotate import SAClient +from src.superannotate.lib.core.usecases import SetApprovalStatues +from tests.integration.base import BaseTestCase + +sa = SAClient() + + +class TestSetApprovalStatuses(BaseTestCase): + PROJECT_NAME = "TestSetApprovalStatuses" + PROJECT_DESCRIPTION = "TestSetApprovalStatuses" + PROJECT_TYPE = "Document" + FOLDER_NAME = "test_folder" + CSV_PATH = "data_set/attach_urls.csv" + EXAMPLE_IMAGE_1 = "6022a74d5384c50017c366b3" + EXAMPLE_IMAGE_2 = "6022a74b5384c50017c366ad" + ATTACHMENT_LIST = [ + { + "url": "https://drive.google.com/uc?export=download&id=1vwfCpTzcjxoEA4hhDxqapPOVvLVeS7ZS", + "name": "6022a74d5384c50017c366b3" + }, + { + "url": "https://drive.google.com/uc?export=download&id=1geS2YtQiTYuiduEirKVYxBujHJaIWA3V", + "name": "6022a74b5384c50017c366ad" + }, + { + "url": "1SfGcn9hdkVM35ZP0S93eStsE7Ti4GtHU", + "path": "123" + }, + { + "url": "https://drive.google.com/uc?export=download&id=1geS2YtQiTYuiduEirKVYxBujHJaIWA3V", + "name": "6022a74b5384c50017c366ad" + }, + ] + + @property + def scv_path(self): + return os.path.join(Path(__file__).parent.parent.parent, self.CSV_PATH) + + def test_image_approval_status(self): + sa.attach_items( + self.PROJECT_NAME, self.ATTACHMENT_LIST + ) + + sa.set_approval_statuses( + self.PROJECT_NAME, "Approved", + ) + for image in sa.search_items(self.PROJECT_NAME): + self.assertEqual(image["approval_status"], "Approved") + + def test_image_approval_status_via_names(self): + sa.attach_items( + self.PROJECT_NAME, self.ATTACHMENT_LIST + ) + + sa.set_approval_statuses( + self.PROJECT_NAME, "Approved", [self.EXAMPLE_IMAGE_1, self.EXAMPLE_IMAGE_2] + ) + + for image_name in [self.EXAMPLE_IMAGE_1, self.EXAMPLE_IMAGE_2]: + metadata = sa.get_item_metadata(self.PROJECT_NAME, image_name) + self.assertEqual(metadata["approval_status"], "Approved") + + def test_image_approval_status_via_invalid_names(self): + sa.attach_items( + self.PROJECT_NAME, self.ATTACHMENT_LIST, "InProgress" + ) + with self.assertRaisesRegexp(AppException, SetApprovalStatues.ERROR_MESSAGE): + sa.set_approval_statuses( + self.PROJECT_NAME, "Approved", ["self.EXAMPLE_IMAGE_1", "self.EXAMPLE_IMAGE_2"] + ) + + def test_set_approval_statuses(self): + sa.attach_items( + self.PROJECT_NAME, [self.ATTACHMENT_LIST[0]] + ) + sa.set_approval_statuses( + self.PROJECT_NAME, approval_status=None, items=[self.ATTACHMENT_LIST[0]["name"]] + ) + data = sa.search_items(self.PROJECT_NAME)[0] + assert data["approval_status"] is None + + def test_set_invalid_approval_statuses(self): + sa.attach_items( + self.PROJECT_NAME, [self.ATTACHMENT_LIST[0]] + ) + with self.assertRaisesRegexp(AppException, 'Available approval_status options are None, Disapproved, Approved.'): + sa.set_approval_statuses( + self.PROJECT_NAME, approval_status="aaa", items=[self.ATTACHMENT_LIST[0]["name"]] + ) + diff --git a/tests/integration/test_benchmark.py b/tests/integration/test_benchmark.py index b5672cc67..e0417690e 100644 --- a/tests/integration/test_benchmark.py +++ b/tests/integration/test_benchmark.py @@ -31,7 +31,7 @@ def folder_path(self): def export_path(self): return os.path.join(dirname(dirname(__file__)), self.TEST_EXPORT_ROOT) - @pytest.mark.flaky(reruns=2) + @pytest.mark.skip("Need to adjust") def test_benchmark(self): sa.create_folder(self.PROJECT_NAME, self.TEST_FOLDER_NAME) annotation_types = ["polygon", "bbox", "point"] From e012dcbef8807ba24e9aa555d596f28f1f0d495d Mon Sep 17 00:00:00 2001 From: Vaghinak Basentsyan <84702976+VaghinakDev@users.noreply.github.com> Date: Wed, 25 Jan 2023 10:31:55 +0400 Subject: [PATCH 18/18] Update __init__.py --- src/superannotate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/superannotate/__init__.py b/src/superannotate/__init__.py index 00f71623c..7836dc997 100644 --- a/src/superannotate/__init__.py +++ b/src/superannotate/__init__.py @@ -2,7 +2,7 @@ import sys import typing -__version__ = "4.4.9dev4" +__version__ = "4.4.9dev5" sys.path.append(os.path.split(os.path.realpath(__file__))[0])