Skip to content

Commit 75ad53e

Browse files
authored
Merge pull request #830 from superannotateai/FRIDAY-4754
add delete_exports
2 parents 0be4bd5 + cb4db93 commit 75ad53e

10 files changed

Lines changed: 285 additions & 3 deletions

File tree

docs/source/api_reference/api_export.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ Exports
77
.. _ref_download_export:
88
.. automethod:: superannotate.SAClient.download_export
99
.. automethod:: superannotate.SAClient.get_exports
10+
.. automethod:: superannotate.SAClient.delete_exports

docs/source/api_reference/helpers.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ _________________________________________________________________
1212
.. _ref_import_annotation_format:
1313
.. autofunction:: superannotate.import_annotation
1414
.. autofunction:: superannotate.export_annotation
15-
.. autofunction:: superannotate.convert_project_type
1615

1716
----------
1817

docs/source/userguide/SDK_Functions_sheet.csv

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ Team,get_team_metadata(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not
8181
"Converting
8282
Annotations",import_annotation(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant
8383
,export_annotation(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant
84-
,convert_project_type(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant
8584
"Working w/
8685
Annotations",validate_annotations(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant
8786
,aggregate_annotations_as_df(),Not Relevant,Not Relevant,Not Relevant,Not Relevant,Not Relevant

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ minversion = 3.7
33
log_cli=true
44
python_files = test_*.py
55
;pytest_plugins = ['pytest_profiling']
6-
addopts = -n 6 --dist loadscope
6+
;addopts = -n 6 --dist loadscope

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2512,6 +2512,48 @@ def prepare_export(
25122512
raise AppException(response.errors)
25132513
return response.data
25142514

2515+
def delete_exports(
2516+
self,
2517+
project: Union[NotEmptyStr, int],
2518+
exports: Union[List[int], List[str], Literal["*"]],
2519+
):
2520+
"""Delete one or more exports from the specified project. The exports argument
2521+
accepts a list of export names or export IDs. The special value “*” means delete all exports.
2522+
2523+
2524+
:param project: The name or ID of the project.
2525+
:type project: Union[NotEmptyStr, int]
2526+
2527+
:param exports: A list of export names or IDs to delete. The special value "*" means delete all exports.
2528+
:type exports: Union[List[int], List[str], Literal["*"]]
2529+
2530+
Request Example:
2531+
::
2532+
2533+
# To delete a specific export
2534+
client.delete_exports(
2535+
project="my_project",
2536+
exports=["TestProject_Jan_30_2026_12_09"]
2537+
)
2538+
2539+
# To delete all exports in the project
2540+
client.delete_exports(
2541+
project="my_project",
2542+
exports="*"
2543+
)
2544+
"""
2545+
project_entity = (
2546+
self.controller.get_project_by_id(project).data
2547+
if isinstance(project, int)
2548+
else self.controller.get_project(project)
2549+
)
2550+
response = self.controller.delete_exports(
2551+
project=project_entity, exports=exports
2552+
)
2553+
if response.errors:
2554+
raise AppException(response.errors)
2555+
logger.info(f"Successfully removed {response.data} export(s).")
2556+
25152557
def upload_videos_from_folder_to_project(
25162558
self,
25172559
project: Union[NotEmptyStr, dict],

src/superannotate/lib/core/serviceproviders.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,14 @@ def get_export(
872872
) -> ServiceResponse:
873873
raise NotImplementedError
874874

875+
@abstractmethod
876+
def delete_export(
877+
self,
878+
project: entities.ProjectEntity,
879+
export_id: int,
880+
) -> ServiceResponse:
881+
raise NotImplementedError
882+
875883
@abstractmethod
876884
def get_project_images_count(
877885
self, project: entities.ProjectEntity

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from pathlib import Path
88
from tempfile import TemporaryDirectory
99
from typing import List
10+
from typing import Literal
11+
from typing import Union
1012

1113
import boto3
1214
import lib.core as constances
@@ -263,6 +265,61 @@ def execute(self):
263265
return self._response
264266

265267

268+
class DeleteExportsUseCase(BaseUseCase):
269+
def __init__(
270+
self,
271+
service_provider: BaseServiceProvider,
272+
project: ProjectEntity,
273+
exports: Union[List[int], List[str], Literal["*"]],
274+
):
275+
super().__init__()
276+
self._service_provider = service_provider
277+
self._project = project
278+
self._exports = exports
279+
280+
def execute(self):
281+
if self.is_valid():
282+
deleted_count = 0
283+
if self._exports:
284+
existing_exports = self._service_provider.get_exports(
285+
self._project
286+
).data
287+
export_ids_to_delete = []
288+
if existing_exports:
289+
if self._exports == "*":
290+
export_ids_to_delete = [exp["id"] for exp in existing_exports]
291+
else:
292+
# drop duplicates
293+
self._exports = list(set(self._exports)) # noqa
294+
295+
if isinstance(self._exports[0], int):
296+
existing_exports_ids = [
297+
int(exp["id"]) for exp in existing_exports
298+
]
299+
export_ids_to_delete = list(
300+
set(self._exports).intersection(
301+
set(existing_exports_ids)
302+
)
303+
)
304+
305+
elif isinstance(self._exports[0], str):
306+
export_ids_to_delete = [
307+
exp["id"]
308+
for exp in existing_exports
309+
if exp["name"] in self._exports
310+
]
311+
312+
for export_id in export_ids_to_delete:
313+
response = self._service_provider.delete_export(
314+
project=self._project, export_id=export_id
315+
)
316+
if response.ok and response.data.get("success"):
317+
deleted_count += 1
318+
self._response.data = deleted_count
319+
320+
return self._response
321+
322+
266323
class ConsensusUseCase(BaseUseCase):
267324
def __init__(
268325
self,

src/superannotate/lib/infrastructure/controller.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,16 @@ def prepare_export(
18101810
)
18111811
return use_case.execute()
18121812

1813+
def delete_exports(
1814+
self, project: ProjectEntity, exports: Union[List[int], List[str], Literal["*"]]
1815+
):
1816+
use_case = usecases.DeleteExportsUseCase(
1817+
service_provider=self.service_provider,
1818+
project=project,
1819+
exports=exports,
1820+
)
1821+
return use_case.execute()
1822+
18131823
def search_team_contributors(self, **kwargs):
18141824
condition = build_condition(**kwargs)
18151825
use_case = usecases.SearchContributorsUseCase(

src/superannotate/lib/infrastructure/serviceprovider.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,17 @@ def get_export(self, project: entities.ProjectEntity, export_id: int):
308308
params={"project_id": project.id},
309309
)
310310

311+
def delete_export(
312+
self,
313+
project: entities.ProjectEntity,
314+
export_id: int,
315+
):
316+
return self.client.request(
317+
f"{self.URL_PREPARE_EXPORT}/{export_id}",
318+
"delete",
319+
params={"project_id": project.id},
320+
)
321+
311322
def get_project_images_count(self, project: entities.ProjectEntity):
312323
return self.client.request(
313324
self.URL_FOLDERS_IMAGES,

tests/integration/export/test_export.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import logging
44
import os
55
import tempfile
6+
import time
67
from unittest import TestCase
78

89
import boto3
910
from src.superannotate import AppException
1011
from src.superannotate import SAClient
1112
from tests import compare_result
13+
from tests.integration.base import BaseTestCase
1214
from tests.integration.export import DATA_SET_PATH
1315

1416
sa = SAClient()
@@ -122,3 +124,156 @@ def test_export_with_statuses(self):
122124
sa.download_export(self.PROJECT_NAME, export, tmpdir_name)
123125
assert not filecmp.dircmp(tmpdir_name, self.TEST_FOLDER_PATH).left_only
124126
assert not filecmp.dircmp(tmpdir_name, self.TEST_FOLDER_PATH).right_only
127+
128+
129+
class TestDeleteExports(BaseTestCase):
130+
PROJECT_NAME = "TestDeleteExports"
131+
PROJECT_DESCRIPTION = "Desc"
132+
PROJECT_TYPE = "Vector"
133+
134+
def _check_all_exports_prepared(self, timeout=60):
135+
"""
136+
Wait for all exports to be prepared and return them.
137+
138+
:param timeout: Maximum time to wait in seconds
139+
:return: List of all exports when all are prepared
140+
:raises TimeoutError: If exports are not ready within timeout
141+
"""
142+
start_time = time.time()
143+
while time.time() - start_time < timeout:
144+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
145+
146+
# Check if all exports are in a final state (not InProgress)
147+
all_ready = all(exp.get("status") == 2 for exp in exports)
148+
149+
if all_ready:
150+
return exports
151+
152+
time.sleep(2)
153+
154+
raise TimeoutError("Exports did not complete within the timeout period")
155+
156+
def test_delete_export_by_name(self):
157+
"""Test deleting a single export by name"""
158+
export1 = sa.prepare_export(self.PROJECT_NAME)
159+
exports = self._check_all_exports_prepared()
160+
assert len(exports) == 1
161+
162+
with self.assertLogs("sa", level="INFO") as cm:
163+
sa.delete_exports(self.PROJECT_NAME, exports=[export1["name"]])
164+
assert "INFO:sa:Successfully removed 1 export(s)." in cm.output[0]
165+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
166+
export_names = [exp["name"] for exp in exports]
167+
168+
assert export1["name"] not in export_names
169+
170+
def test_delete_export_by_id(self):
171+
"""Test deleting a single export by ID"""
172+
export1 = sa.prepare_export(self.PROJECT_NAME)
173+
exports = self._check_all_exports_prepared()
174+
assert len(exports) == 1
175+
176+
export2 = sa.prepare_export(self.PROJECT_NAME)
177+
exports = self._check_all_exports_prepared()
178+
assert len(exports) == 2
179+
180+
sa.delete_exports(self.PROJECT_NAME, exports=[export1["id"]])
181+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
182+
export_ids = [exp["id"] for exp in exports]
183+
184+
assert export1["id"] not in export_ids
185+
assert export2["id"] in export_ids
186+
187+
def test_delete_multiple_exports(self):
188+
"""Test deleting multiple exports by name and ID"""
189+
export1 = sa.prepare_export(self.PROJECT_NAME)
190+
exports = self._check_all_exports_prepared()
191+
assert len(exports) == 1
192+
193+
export2 = sa.prepare_export(self.PROJECT_NAME)
194+
exports = self._check_all_exports_prepared()
195+
assert len(exports) == 2
196+
197+
export3 = sa.prepare_export(self.PROJECT_NAME)
198+
exports = self._check_all_exports_prepared()
199+
assert len(exports) == 3
200+
201+
sa.delete_exports(self.PROJECT_NAME, exports=[export1["id"], export2["id"]])
202+
203+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
204+
export_ids = [exp["id"] for exp in exports]
205+
206+
assert export1["id"] not in export_ids
207+
assert export2["id"] not in export_ids
208+
assert export3["id"] in export_ids
209+
210+
def test_delete_all_exports(self):
211+
"""Test deleting all exports using '*'"""
212+
export1 = sa.prepare_export(self.PROJECT_NAME)
213+
exports = self._check_all_exports_prepared()
214+
assert len(exports) == 1
215+
216+
export2 = sa.prepare_export(self.PROJECT_NAME)
217+
exports = self._check_all_exports_prepared()
218+
assert len(exports) == 2
219+
220+
sa.delete_exports(self.PROJECT_NAME, exports="*")
221+
222+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
223+
assert len(exports) == 0
224+
225+
def test_delete_nonexistent_export(self):
226+
"""Test deleting a non-existent export (should not raise error)"""
227+
export1 = sa.prepare_export(self.PROJECT_NAME)
228+
exports = self._check_all_exports_prepared()
229+
assert len(exports) == 1
230+
231+
# Should not raise error for non-existent export
232+
with self.assertLogs("sa", level="INFO") as cm:
233+
sa.delete_exports(
234+
self.PROJECT_NAME, exports=["nonexistent_export", export1["name"]]
235+
)
236+
assert "Successfully removed 1 export(s)." in cm.output[0]
237+
238+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
239+
export_names = [exp["name"] for exp in exports]
240+
241+
assert export1["name"] not in export_names
242+
243+
def test_delete_exports_empty_list(self):
244+
"""Test deleting with empty list"""
245+
export1 = sa.prepare_export(self.PROJECT_NAME)
246+
exports = self._check_all_exports_prepared()
247+
assert len(exports) == 1
248+
249+
with self.assertLogs("sa", level="INFO") as cm:
250+
sa.delete_exports(self.PROJECT_NAME, exports=[])
251+
assert "Successfully removed 0 export(s)." in cm.output[0]
252+
253+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
254+
assert len(exports) == 1
255+
256+
def test_delete_exports_project_not_found(self):
257+
"""Test deleting exports from non-existent project"""
258+
with self.assertRaisesRegexp(AppException, "Project not found"):
259+
sa.delete_exports("NonExistentProject123456", exports=["*"])
260+
261+
def test_delete_mixed_valid_invalid_exports(self):
262+
"""Test deleting mix of valid and invalid export identifiers"""
263+
export1 = sa.prepare_export(self.PROJECT_NAME)
264+
exports = self._check_all_exports_prepared()
265+
assert len(exports) == 1
266+
267+
export2 = sa.prepare_export(self.PROJECT_NAME)
268+
exports = self._check_all_exports_prepared()
269+
assert len(exports) == 2
270+
271+
with self.assertLogs("sa", level="INFO") as cm:
272+
sa.delete_exports(
273+
self.PROJECT_NAME,
274+
exports=[export1["name"], "invalid_name", 99999, export2["id"]],
275+
)
276+
assert "Successfully removed 2 export(s)." in cm.output[0]
277+
278+
exports = sa.get_exports(self.PROJECT_NAME, return_metadata=True)
279+
assert len(exports) == 0

0 commit comments

Comments
 (0)