Skip to content

Commit b7e9a82

Browse files
Narek MkhitaryanNarek Mkhitaryan
authored andcommitted
added new CopyMoveItems usecase
1 parent 14a0632 commit b7e9a82

File tree

6 files changed

+316
-25
lines changed

6 files changed

+316
-25
lines changed

src/superannotate/lib/app/interface/sdk_interface.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2318,7 +2318,7 @@ def invite_contributors_to_team(
23182318

23192319
def get_annotations(
23202320
self,
2321-
project: Union[int, NotEmptyStr],
2321+
project: Union[NotEmptyStr, int],
23222322
items: Optional[Union[List[NotEmptyStr], List[int]]] = None,
23232323
):
23242324
"""Returns annotations for the given list of items.
@@ -2928,21 +2928,35 @@ def copy_items(
29282928
destination: Union[NotEmptyStr, dict],
29292929
items: Optional[List[NotEmptyStr]] = None,
29302930
include_annotations: Optional[bool] = True,
2931+
duplicate_strategy: Literal[
2932+
"skip", "replace", "replace_annotations_only"
2933+
] = "skip",
29312934
):
29322935
"""Copy images in bulk between folders in a project
29332936
2934-
:param source: project name or folder path to select items from (e.g., “project1/folder1”).
2937+
:param source: project name (root) or folder path to pick items from (e.g., “project1/folder1”).
29352938
:type source: str
29362939
2937-
:param destination: project name (root) or folder path to place copied items.
2940+
:param destination: project name (root) or folder path to place copied items (e.g., “project1/folder2”).
29382941
:type destination: str
29392942
29402943
:param items: names of items to copy. If None, all items from the source directory will be copied.
29412944
:type items: list of str
29422945
2943-
:param include_annotations: enables annotations copy
2946+
:param include_annotations: enables the copying of item data, including annotations, status, priority score,
2947+
approval state, and category. If set to False, only the items will be copied without additional data.
29442948
:type include_annotations: bool
29452949
2950+
:param duplicate_strategy: Specifies the strategy for handling duplicate items in the destination.
2951+
The default value is "skip".
2952+
2953+
- "skip": skips duplicate items in the destination and continues with the next item.
2954+
- "replace": replaces the annotations, status, priority score, approval state, and category of duplicate items.
2955+
- "replace_annotations_only": replaces only the annotations of duplicate items,
2956+
leaving other data (status, priority score, approval state, and category) unchanged.
2957+
2958+
:type duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"]
2959+
29462960
:return: list of skipped item names
29472961
:rtype: list of strs
29482962
"""
@@ -2960,6 +2974,7 @@ def copy_items(
29602974
to_folder=to_folder,
29612975
item_names=items,
29622976
include_annotations=include_annotations,
2977+
duplicate_strategy=duplicate_strategy,
29632978
)
29642979
if response.errors:
29652980
raise AppException(response.errors)
@@ -2971,18 +2986,31 @@ def move_items(
29712986
source: Union[NotEmptyStr, dict],
29722987
destination: Union[NotEmptyStr, dict],
29732988
items: Optional[List[NotEmptyStr]] = None,
2989+
duplicate_strategy: Literal[
2990+
"skip", "replace", "replace_annotations_only"
2991+
] = "skip",
29742992
):
29752993
"""Move images in bulk between folders in a project
29762994
2977-
:param source: project name or folder path to pick items from (e.g., “project1/folder1”).
2995+
:param source: project name (root) or folder path to pick items from (e.g., “project1/folder1”).
29782996
:type source: str
29792997
2980-
:param destination: project name (root) or folder path to move items to.
2998+
:param destination: project name (root) or folder path to move items to (e.g., “project1/folder2”).
29812999
:type destination: str
29823000
29833001
:param items: names of items to move. If None, all items from the source directory will be moved.
29843002
:type items: list of str
29853003
3004+
:param duplicate_strategy: Specifies the strategy for handling duplicate items in the destination.
3005+
The default value is "skip".
3006+
3007+
- "skip": skips duplicate items in the destination and continues with the next item.
3008+
- "replace": replaces the annotations, status, priority score, approval state, and category of duplicate items.
3009+
- "replace_annotations_only": replaces only the annotations of duplicate items,
3010+
leaving other data (status, priority score, approval state, and category) unchanged.
3011+
3012+
:type duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"]
3013+
29863014
:return: list of skipped item names
29873015
:rtype: list of strs
29883016
"""
@@ -3000,6 +3028,7 @@ def move_items(
30003028
from_folder=source_folder,
30013029
to_folder=destination_folder,
30023030
item_names=items,
3031+
duplicate_strategy=duplicate_strategy,
30033032
)
30043033
if response.errors:
30053034
raise AppException(response.errors)

src/superannotate/lib/core/serviceproviders.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Callable
66
from typing import Dict
77
from typing import List
8+
from typing import Literal
89

910
from lib.core import entities
1011
from lib.core.conditions import Condition
@@ -287,10 +288,30 @@ def copy_multiple(
287288
) -> ServiceResponse:
288289
raise NotImplementedError
289290

291+
@abstractmethod
292+
def copy_move_multiple(
293+
self,
294+
project: entities.ProjectEntity,
295+
from_folder: entities.FolderEntity,
296+
to_folder: entities.FolderEntity,
297+
item_names: List[str],
298+
duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"],
299+
operation: Literal["copy", "move"],
300+
include_annotations: bool = False,
301+
include_pin: bool = False,
302+
) -> ServiceResponse:
303+
raise NotImplementedError
304+
290305
@abstractmethod
291306
def await_copy(self, project: entities.ProjectEntity, poll_id: int, items_count):
292307
raise NotImplementedError
293308

309+
@abstractmethod
310+
def await_copy_move(
311+
self, project: entities.ProjectEntity, poll_id: int, items_count
312+
):
313+
raise NotImplementedError
314+
294315
@abstractmethod
295316
def set_statuses(
296317
self,

src/superannotate/lib/core/usecases/items.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from lib.core.usecases.folders import SearchFoldersUseCase
3434
from lib.infrastructure.utils import divide_to_chunks
3535
from lib.infrastructure.utils import extract_project_folder
36+
from typing_extensions import Literal
3637

3738
logger = logging.getLogger("sa")
3839

@@ -537,6 +538,154 @@ def execute(self):
537538
return self._response
538539

539540

541+
class CopyMoveItems(BaseReportableUseCase):
542+
"""
543+
Copy/Move items in bulk between folders in a project.
544+
Return skipped item names.
545+
"""
546+
547+
def __init__(
548+
self,
549+
reporter: Reporter,
550+
project: ProjectEntity,
551+
from_folder: FolderEntity,
552+
to_folder: FolderEntity,
553+
item_names: List[str],
554+
service_provider: BaseServiceProvider,
555+
include_annotations: bool,
556+
duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"],
557+
operation: Literal["copy", "move"],
558+
chunk_size: int = 1000,
559+
):
560+
super().__init__(reporter)
561+
self._project = project
562+
self._from_folder = from_folder
563+
self._to_folder = to_folder
564+
self._item_names = item_names
565+
self._service_provider = service_provider
566+
self._include_annotations = include_annotations
567+
self._duplicate_strategy = duplicate_strategy
568+
self._operation = operation
569+
self._chunk_size = chunk_size
570+
571+
def _validate_limitations(self, items_count):
572+
response = self._service_provider.get_limitations(
573+
project=self._project,
574+
folder=self._to_folder,
575+
)
576+
if not response.ok:
577+
raise AppValidationException(response.error)
578+
if self._operation == "copy":
579+
folder_limit_err_msg = constants.COPY_FOLDER_LIMIT_ERROR_MESSAGE
580+
project_limit_err_msg = constants.COPY_PROJECT_LIMIT_ERROR_MESSAGE
581+
else:
582+
folder_limit_err_msg = constants.MOVE_FOLDER_LIMIT_ERROR_MESSAGE
583+
project_limit_err_msg = constants.MOVE_PROJECT_LIMIT_ERROR_MESSAGE
584+
if items_count > response.data.folder_limit.remaining_image_count:
585+
raise AppValidationException(folder_limit_err_msg)
586+
if items_count > response.data.project_limit.remaining_image_count:
587+
raise AppValidationException(project_limit_err_msg)
588+
589+
def validate_item_names(self):
590+
if self._item_names:
591+
self._item_names = list(set(self._item_names))
592+
593+
def execute(self):
594+
if self.is_valid():
595+
if self._item_names:
596+
items = self._item_names
597+
else:
598+
res = self._service_provider.item_service.list(
599+
self._project.id, self._from_folder.id, EmptyQuery()
600+
)
601+
if res.error:
602+
raise AppException(res.error)
603+
items = [i.name for i in res.data]
604+
try:
605+
self._validate_limitations(len(items))
606+
except AppValidationException as e:
607+
self._response.errors = e
608+
return self._response
609+
skipped_items = []
610+
if self._duplicate_strategy == "skip":
611+
existing_items = []
612+
for i in range(0, len(items), self._chunk_size):
613+
query = Filter(
614+
"name", items[i : i + self._chunk_size], OperatorEnum.IN
615+
) # noqa
616+
res = self._service_provider.item_service.list(
617+
self._project.id, self._to_folder.id, query
618+
)
619+
if res.error:
620+
raise AppException(res.error)
621+
if not res.data:
622+
continue
623+
existing_items += res.data
624+
duplications = [item.name for item in existing_items]
625+
items_to_processing = list(set(items) - set(duplications))
626+
skipped_items.extend(duplications)
627+
else:
628+
items_to_processing = items
629+
if items_to_processing:
630+
for i in range(0, len(items_to_processing), self._chunk_size):
631+
chunk_to_process = items_to_processing[
632+
i : i + self._chunk_size
633+
] # noqa: E203
634+
response = self._service_provider.items.copy_move_multiple(
635+
project=self._project,
636+
from_folder=self._from_folder,
637+
to_folder=self._to_folder,
638+
item_names=chunk_to_process,
639+
include_annotations=self._include_annotations,
640+
duplicate_strategy=self._duplicate_strategy,
641+
operation=self._operation,
642+
)
643+
if not response.ok or not response.data.get("poll_id"):
644+
skipped_items.extend(chunk_to_process)
645+
continue
646+
try:
647+
self._service_provider.items.await_copy_move(
648+
project=self._project,
649+
poll_id=response.data["poll_id"],
650+
items_count=len(chunk_to_process),
651+
)
652+
except BackendError as e:
653+
self._response.errors = AppException(e)
654+
return self._response
655+
existing_items = []
656+
for i in range(0, len(items_to_processing), self._chunk_size):
657+
res = self._service_provider.item_service.list(
658+
self._project.id,
659+
self._to_folder.id,
660+
Filter(
661+
"name",
662+
items_to_processing[i : i + self._chunk_size],
663+
OperatorEnum.IN,
664+
), # noqa
665+
)
666+
if res.error:
667+
raise AppException(res.error)
668+
669+
existing_items += res.data
670+
671+
existing_item_names_set = {item.name for item in existing_items}
672+
items_to_processing_names_set = set(items_to_processing)
673+
processed_items = existing_item_names_set.intersection(
674+
items_to_processing_names_set
675+
)
676+
skipped_items.extend(
677+
list(items_to_processing_names_set - processed_items)
678+
)
679+
operation_processing_map = {"copy": "Copied", "move": "Moved"}
680+
self.reporter.log_info(
681+
f"{operation_processing_map[self._operation]} {len(processed_items)}/{len(items_to_processing)} item(s) from "
682+
f"{self._project.name}{'' if self._from_folder.is_root else f'/{self._from_folder.name}'} to "
683+
f"{self._project.name}{'' if self._to_folder.is_root else f'/{self._to_folder.name}'}"
684+
)
685+
self._response.data = list(set(skipped_items))
686+
return self._response
687+
688+
540689
class SetAnnotationStatues(BaseReportableUseCase):
541690
CHUNK_SIZE = 500
542691
ERROR_MESSAGE = "Failed to change status"

src/superannotate/lib/infrastructure/controller.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Callable
77
from typing import Dict
88
from typing import List
9+
from typing import Literal
910
from typing import Optional
1011
from typing import Tuple
1112
from typing import Union
@@ -28,6 +29,7 @@
2829
from lib.core.entities import UserEntity
2930
from lib.core.entities.classes import AnnotationClassEntity
3031
from lib.core.entities.integrations import IntegrationEntity
32+
from lib.core.enums import ProjectType
3133
from lib.core.exceptions import AppException
3234
from lib.core.jsx_conditions import EmptyQuery
3335
from lib.core.jsx_conditions import Filter
@@ -540,35 +542,65 @@ def copy_multiple(
540542
project: ProjectEntity,
541543
from_folder: FolderEntity,
542544
to_folder: FolderEntity,
545+
duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"],
543546
item_names: List[str] = None,
544547
include_annotations: bool = False,
545548
):
546-
use_case = usecases.CopyItems(
547-
reporter=Reporter(),
548-
project=project,
549-
from_folder=from_folder,
550-
to_folder=to_folder,
551-
item_names=item_names,
552-
service_provider=self.service_provider,
553-
include_annotations=include_annotations,
554-
)
549+
if project.type == ProjectType.PIXEL:
550+
use_case = usecases.CopyItems(
551+
reporter=Reporter(),
552+
project=project,
553+
from_folder=from_folder,
554+
to_folder=to_folder,
555+
item_names=item_names,
556+
service_provider=self.service_provider,
557+
include_annotations=include_annotations,
558+
)
559+
else:
560+
use_case = usecases.CopyMoveItems(
561+
reporter=Reporter(),
562+
project=project,
563+
from_folder=from_folder,
564+
to_folder=to_folder,
565+
item_names=item_names,
566+
service_provider=self.service_provider,
567+
include_annotations=include_annotations,
568+
duplicate_strategy=duplicate_strategy,
569+
operation="copy",
570+
chunk_size=500,
571+
)
555572
return use_case.execute()
556573

557574
def move_multiple(
558575
self,
559576
project: ProjectEntity,
560577
from_folder: FolderEntity,
561578
to_folder: FolderEntity,
579+
duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"],
562580
item_names: List[str] = None,
563581
):
564-
use_case = usecases.MoveItems(
565-
reporter=Reporter(),
566-
project=project,
567-
from_folder=from_folder,
568-
to_folder=to_folder,
569-
item_names=item_names,
570-
service_provider=self.service_provider,
571-
)
582+
if project.type == ProjectType.PIXEL:
583+
use_case = usecases.MoveItems(
584+
reporter=Reporter(),
585+
project=project,
586+
from_folder=from_folder,
587+
to_folder=to_folder,
588+
item_names=item_names,
589+
service_provider=self.service_provider,
590+
)
591+
else:
592+
use_case = usecases.CopyMoveItems(
593+
reporter=Reporter(),
594+
project=project,
595+
from_folder=from_folder,
596+
to_folder=to_folder,
597+
item_names=item_names,
598+
service_provider=self.service_provider,
599+
duplicate_strategy=duplicate_strategy,
600+
include_annotations=True,
601+
operation="move",
602+
chunk_size=1000,
603+
)
572604
return use_case.execute()
573605

574606
def set_annotation_statuses(

0 commit comments

Comments
 (0)