Skip to content

Commit 01c69fc

Browse files
authored
Merge pull request #720 from superannotateai/FRIDAY-3239
added new CopyMoveItems usecase
2 parents e06c8ed + 5e32e03 commit 01c69fc

File tree

8 files changed

+513
-32
lines changed

8 files changed

+513
-32
lines changed

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

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2927,26 +2927,46 @@ def copy_items(
29272927
source: Union[NotEmptyStr, dict],
29282928
destination: Union[NotEmptyStr, dict],
29292929
items: Optional[List[NotEmptyStr]] = None,
2930-
include_annotations: Optional[bool] = True,
2930+
include_annotations: 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
"""
29492963

2964+
if not include_annotations and duplicate_strategy != "skip":
2965+
duplicate_strategy = "skip"
2966+
logger.warning(
2967+
"Copy operation continuing without annotations and metadata due to include_annotations=False."
2968+
)
2969+
29502970
project_name, source_folder = extract_project_folder(source)
29512971
to_project_name, destination_folder = extract_project_folder(destination)
29522972
if project_name != to_project_name:
@@ -2960,6 +2980,7 @@ def copy_items(
29602980
to_folder=to_folder,
29612981
item_names=items,
29622982
include_annotations=include_annotations,
2983+
duplicate_strategy=duplicate_strategy,
29632984
)
29642985
if response.errors:
29652986
raise AppException(response.errors)
@@ -2971,18 +2992,31 @@ def move_items(
29712992
source: Union[NotEmptyStr, dict],
29722993
destination: Union[NotEmptyStr, dict],
29732994
items: Optional[List[NotEmptyStr]] = None,
2995+
duplicate_strategy: Literal[
2996+
"skip", "replace", "replace_annotations_only"
2997+
] = "skip",
29742998
):
29752999
"""Move images in bulk between folders in a project
29763000
2977-
:param source: project name or folder path to pick items from (e.g., “project1/folder1”).
3001+
:param source: project name (root) or folder path to pick items from (e.g., “project1/folder1”).
29783002
:type source: str
29793003
2980-
:param destination: project name (root) or folder path to move items to.
3004+
:param destination: project name (root) or folder path to move items to (e.g., “project1/folder2”).
29813005
:type destination: str
29823006
29833007
:param items: names of items to move. If None, all items from the source directory will be moved.
29843008
:type items: list of str
29853009
3010+
:param duplicate_strategy: Specifies the strategy for handling duplicate items in the destination.
3011+
The default value is "skip".
3012+
3013+
- "skip": skips duplicate items in the destination and continues with the next item.
3014+
- "replace": replaces the annotations, status, priority score, approval state, and category of duplicate items.
3015+
- "replace_annotations_only": replaces only the annotations of duplicate items,
3016+
leaving other data (status, priority score, approval state, and category) unchanged.
3017+
3018+
:type duplicate_strategy: Literal["skip", "replace", "replace_annotations_only"]
3019+
29863020
:return: list of skipped item names
29873021
:rtype: list of strs
29883022
"""
@@ -3000,6 +3034,7 @@ def move_items(
30003034
from_folder=source_folder,
30013035
to_folder=destination_folder,
30023036
item_names=items,
3037+
duplicate_strategy=duplicate_strategy,
30033038
)
30043039
if response.errors:
30053040
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 = True,
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"

0 commit comments

Comments
 (0)