Skip to content

Commit 9e9582a

Browse files
committed
Added attach_items
1 parent 54edda5 commit 9e9582a

File tree

10 files changed

+359
-9
lines changed

10 files changed

+359
-9
lines changed

src/superannotate/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
attach_document_urls_to_project,
2626
)
2727
from superannotate.lib.app.interface.sdk_interface import attach_image_urls_to_project
28+
from superannotate.lib.app.interface.sdk_interface import attach_items
2829
from superannotate.lib.app.interface.sdk_interface import (
2930
attach_items_from_integrated_storage,
3031
)
@@ -175,6 +176,7 @@
175176
"get_item_metadata",
176177
"search_items",
177178
"query",
179+
"attach_items",
178180
# Image Section
179181
"copy_images",
180182
"move_images",

src/superannotate/lib/app/helpers.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import boto3
1010
import pandas as pd
11+
from superannotate.lib.app.exceptions import AppException
1112
from superannotate.lib.app.exceptions import PathError
1213
from superannotate.lib.core import ATTACHED_VIDEO_ANNOTATION_POSTFIX
1314
from superannotate.lib.core import PIXEL_ANNOTATION_POSTFIX
@@ -168,3 +169,33 @@ def get_paths_and_duplicated_from_csv(csv_path):
168169
else:
169170
duplicate_images.append(temp)
170171
return images_to_upload, duplicate_images
172+
173+
174+
def get_name_url_duplicated_from_csv(csv_path):
175+
image_data = pd.read_csv(csv_path, dtype=str)
176+
if "url" not in image_data.columns:
177+
raise AppException("Column 'url' is required")
178+
image_data = image_data[~image_data["url"].isnull()]
179+
if "name" in image_data.columns:
180+
image_data["name"] = (
181+
image_data["name"]
182+
.fillna("")
183+
.apply(lambda cell: cell if str(cell).strip() else str(uuid.uuid4()))
184+
)
185+
else:
186+
image_data["name"] = [str(uuid.uuid4()) for _ in range(len(image_data.index))]
187+
188+
image_data = pd.DataFrame(image_data, columns=["name", "url"])
189+
img_names_urls = image_data.to_dict(orient="records")
190+
duplicate_images = []
191+
seen = []
192+
images_to_upload = []
193+
for i in img_names_urls:
194+
temp = i["name"]
195+
i["name"] = i["name"].strip()
196+
if i["name"] not in seen:
197+
seen.append(i["name"])
198+
images_to_upload.append(i)
199+
else:
200+
duplicate_images.append(temp)
201+
return images_to_upload, duplicate_images

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

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import io
23
import json
34
import os
@@ -17,10 +18,13 @@
1718
from lib.app.annotation_helpers import add_annotation_point_to_json
1819
from lib.app.helpers import extract_project_folder
1920
from lib.app.helpers import get_annotation_paths
21+
from lib.app.helpers import get_name_url_duplicated_from_csv
2022
from lib.app.helpers import get_paths_and_duplicated_from_csv
2123
from lib.app.interface.types import AnnotationStatuses
2224
from lib.app.interface.types import AnnotationType
2325
from lib.app.interface.types import AnnotatorRole
26+
from lib.app.interface.types import AttachmentArg
27+
from lib.app.interface.types import AttachmentDict
2428
from lib.app.interface.types import ClassType
2529
from lib.app.interface.types import EmailStr
2630
from lib.app.interface.types import ImageQualityChoices
@@ -36,6 +40,7 @@
3640
from lib.app.serializers import SettingsSerializer
3741
from lib.app.serializers import TeamSerializer
3842
from lib.core import LIMITED_FUNCTIONS
43+
from lib.core.entities import AttachmentEntity
3944
from lib.core.entities.integrations import IntegrationEntity
4045
from lib.core.entities.project_entities import AnnotationClassEntity
4146
from lib.core.enums import ImageQuality
@@ -297,6 +302,7 @@ def search_images(
297302
"We're deprecating the search_images function. Please use search_items instead. Learn more."
298303
"https://superannotate.readthedocs.io/en/stable/superannotate.sdk.html#superannotate.search_items"
299304
)
305+
logger.warning(warning_msg)
300306
warnings.warn(warning_msg, DeprecationWarning)
301307
project_name, folder_name = extract_project_folder(project)
302308
project = Controller.get_default()._get_project(project_name)
@@ -1810,6 +1816,12 @@ def attach_image_urls_to_project(
18101816
:return: list of linked image names, list of failed image names, list of duplicate image names
18111817
:rtype: tuple
18121818
"""
1819+
warning_msg = (
1820+
"We're deprecating the attach_image_urls_to_project function. Please use attach_items instead. Learn more."
1821+
"https://superannotate.readthedocs.io/en/stable/superannotate.sdk.html#superannotate.attach_items"
1822+
)
1823+
logger.warning(warning_msg)
1824+
warnings.warn(warning_msg, DeprecationWarning)
18131825
project_name, folder_name = extract_project_folder(project)
18141826
project = Controller.get_default().get_project_metadata(project_name).data
18151827
project_folder_name = project_name + (f"/{folder_name}" if folder_name else "")
@@ -1877,6 +1889,12 @@ def attach_video_urls_to_project(
18771889
:return: attached videos, failed videos, skipped videos
18781890
:rtype: (list, list, list)
18791891
"""
1892+
warning_msg = (
1893+
"We're deprecating the attach_video_urls_to_project function. Please use attach_items instead. Learn more."
1894+
"https://superannotate.readthedocs.io/en/stable/superannotate.sdk.html#superannotate.attach_items"
1895+
)
1896+
logger.warning(warning_msg)
1897+
warnings.warn(warning_msg, DeprecationWarning)
18801898
project_name, folder_name = extract_project_folder(project)
18811899
project = Controller.get_default().get_project_metadata(project_name).data
18821900
project_folder_name = project_name + (f"/{folder_name}" if folder_name else "")
@@ -2479,8 +2497,10 @@ def search_images_all_folders(
24792497
24802498
:param project: project name
24812499
:type project: str
2500+
24822501
:param image_name_prefix: image name prefix for search
24832502
:type image_name_prefix: str
2503+
24842504
:param annotation_status: if not None, annotation statuses of images to filter,
24852505
should be one of NotStarted InProgress QualityCheck Returned Completed Skipped
24862506
:type annotation_status: str
@@ -2735,6 +2755,12 @@ def attach_document_urls_to_project(
27352755
:return: list of attached documents, list of not attached documents, list of skipped documents
27362756
:rtype: tuple
27372757
"""
2758+
warning_msg = (
2759+
"We're deprecating the attach_document_urls_to_project function. Please use attach_items instead. Learn more."
2760+
"https://superannotate.readthedocs.io/en/stable/superannotate.sdk.html#superannotate.attach_items"
2761+
)
2762+
logger.warning(warning_msg)
2763+
warnings.warn(warning_msg, DeprecationWarning)
27382764
project_name, folder_name = extract_project_folder(project)
27392765
project = Controller.get_default().get_project_metadata(project_name).data
27402766
project_folder_name = project_name + (f"/{folder_name}" if folder_name else "")
@@ -3087,3 +3113,64 @@ def search_items(
30873113
if response.errors:
30883114
raise AppException(response.errors)
30893115
return BaseSerializer.serialize_iterable(response.data)
3116+
3117+
3118+
@Trackable
3119+
@validate_arguments
3120+
def attach_items(
3121+
project: Union[NotEmptyStr, dict],
3122+
attachments: AttachmentArg,
3123+
annotation_status: Optional[AnnotationStatuses] = "NotStarted"
3124+
):
3125+
"""Link items from external storage to SuperAnnotate using URLs.
3126+
3127+
:param project: project name or folder path (e.g., “project1/folder1”)
3128+
:type project: str
3129+
3130+
:param attachments: path to CSV file or list of dicts containing attachments URLs.
3131+
:type attachments: path-like (str or Path) or list of dicts
3132+
3133+
:param annotation_status: value to set the annotation statuses of the
3134+
linked items:
3135+
“NotStarted”
3136+
“InProgress”
3137+
“QualityCheck”
3138+
“Returned”
3139+
“Completed”
3140+
“Skipped”
3141+
:type annotation_status: str
3142+
3143+
:return: list of attached item names, list of not attached item names, list of duplicate item names
3144+
that are already in SuperAnnotate.
3145+
:rtype: tuple
3146+
"""
3147+
attachments = attachments.__root__
3148+
project_name, folder_name = extract_project_folder(project)
3149+
if attachments and isinstance(attachments[0], AttachmentDict):
3150+
unique_attachments = set(attachments)
3151+
duplicate_attachments = [item for item, count in collections.Counter(attachments).items() if count > 1]
3152+
else:
3153+
unique_attachments, duplicate_attachments = get_name_url_duplicated_from_csv(attachments)
3154+
3155+
if duplicate_attachments:
3156+
logger.info("Dropping duplicates.")
3157+
unique_attachments = parse_obj_as(List[AttachmentEntity], unique_attachments)
3158+
if unique_attachments:
3159+
logger.info(f"Attaching {len(unique_attachments)} file(s) to project {project}.")
3160+
response = Controller.get_default().attach_items(
3161+
project_name=project_name,
3162+
folder_name=folder_name,
3163+
attachments=unique_attachments,
3164+
annotation_status=annotation_status,
3165+
)
3166+
if response.errors:
3167+
raise AppException(response.errors)
3168+
3169+
uploaded, duplicated = response.data
3170+
uploaded = [i["name"] for i in uploaded]
3171+
fails = [
3172+
attachment.name
3173+
for attachment in unique_attachments
3174+
if attachment.name not in uploaded and attachment.name not in duplicated
3175+
]
3176+
return uploaded, fails, duplicated

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import uuid
12
from functools import wraps
3+
from pathlib import Path
4+
from typing import Optional
25
from typing import Union
36

47
from lib.core.enums import AnnotationStatus
@@ -8,7 +11,13 @@
811
from lib.core.enums import UserRole
912
from lib.core.exceptions import AppException
1013
from lib.infrastructure.validators import wrap_error
14+
from pydantic import BaseModel
15+
from pydantic import conlist
1116
from pydantic import constr
17+
from pydantic import Extra
18+
from pydantic import Field
19+
from pydantic import parse_obj_as
20+
from pydantic import root_validator
1221
from pydantic import StrictStr
1322
from pydantic import validate_arguments as pydantic_validate_arguments
1423
from pydantic import ValidationError
@@ -22,7 +31,9 @@ class EmailStr(StrictStr):
2231
def validate(cls, value: Union[str]) -> Union[str]:
2332
try:
2433
constr(
25-
regex=r"^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
34+
regex=r"^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)"
35+
r"*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}"
36+
r"[a-zA-Z0-9])?)*$"
2637
).validate(value)
2738
except StrRegexError:
2839
raise ValueError("Invalid email")
@@ -79,6 +90,40 @@ def validate(cls, value: Union[str]) -> Union[str]:
7990
return value
8091

8192

93+
class AttachmentDict(BaseModel):
94+
url: StrictStr
95+
name: Optional[StrictStr] = Field(default_factory=lambda: str(uuid.uuid4()))
96+
97+
class Config:
98+
extra = Extra.ignore
99+
100+
def __hash__(self):
101+
return hash(self.name)
102+
103+
def __eq__(self, other):
104+
return self.url == other.url and self.name.strip() == other.name.strip()
105+
106+
107+
AttachmentArgType = Union[NotEmptyStr, Path, conlist(AttachmentDict, min_items=1)]
108+
109+
110+
class AttachmentArg(BaseModel):
111+
__root__: AttachmentArgType
112+
113+
def __getitem__(self, index):
114+
return self.__root__[index]
115+
116+
@root_validator(pre=True)
117+
def validate_root(cls, values):
118+
try:
119+
parse_obj_as(AttachmentArgType, values["__root__"])
120+
except ValidationError:
121+
raise ValueError(
122+
"The value must be str, path, or list of dicts with the required 'url' and optional 'name' keys"
123+
)
124+
return values
125+
126+
82127
class ImageQualityChoices(StrictStr):
83128
VALID_CHOICES = ["compressed", "original"]
84129

src/superannotate/lib/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
ALREADY_EXISTING_FILES_WARNING = (
6262
"{} already existing file(s) found that won't be uploaded."
6363
)
64+
6465
ATTACHING_FILES_MESSAGE = "Attaching {} file(s) to project {}."
6566

6667
ATTACHING_UPLOAD_STATE_ERROR = "You cannot attach URLs in this type of project. Please attach it in an external storage project."

src/superannotate/lib/core/entities/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from lib.core.entities.base import AttachmentEntity
12
from lib.core.entities.base import BaseEntity as TmpBaseEntity
23
from lib.core.entities.integrations import IntegrationEntity
34
from lib.core.entities.items import DocumentEntity
@@ -33,6 +34,8 @@
3334
"Entity",
3435
"VideoEntity",
3536
"DocumentEntity",
37+
# Utils
38+
"AttachmentEntity",
3639
# project
3740
"ProjectEntity",
3841
"ProjectSettingEntity",

src/superannotate/lib/core/entities/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import uuid
12
from datetime import datetime
23
from typing import Optional
34

@@ -25,3 +26,11 @@ class BaseEntity(TimedBaseModel):
2526

2627
class Config:
2728
extra = Extra.allow
29+
30+
31+
class AttachmentEntity(BaseModel):
32+
name: Optional[str] = Field(default_factory=lambda: str(uuid.uuid4()))
33+
url: str
34+
35+
class Config:
36+
extra = Extra.ignore

0 commit comments

Comments
 (0)