Skip to content

Commit 6cc0410

Browse files
audristroyerclaude
andcommitted
Add soft-delete API support: when param, undelete, list-deleted (closes #1)
- CloudClient.delete() now accepts params dict for query parameters - delete_dataset/delete_document/bulk_delete accept when='7d' (default) - New: undelete_dataset, list_deleted_datasets, list_deleted_documents - All test cleanup uses when='now' for immediate deletion - New TestSoftDelete class with 5 live API tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1192710 commit 6cc0410

4 files changed

Lines changed: 275 additions & 18 deletions

File tree

src/ndi/cloud/api/datasets.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,26 @@ def update_dataset(
5353
)
5454

5555

56-
def delete_dataset(client: CloudClient, dataset_id: str) -> bool:
57-
"""DELETE /datasets/{datasetId}"""
58-
client.delete("/datasets/{datasetId}", datasetId=dataset_id)
59-
return True
56+
def delete_dataset(
57+
client: CloudClient,
58+
dataset_id: str,
59+
when: str = "7d",
60+
) -> dict[str, Any]:
61+
"""DELETE /datasets/{datasetId}?when=...
62+
63+
Soft-delete a dataset. The *when* parameter controls how long before
64+
the dataset and its documents are permanently pruned:
65+
66+
- ``'now'`` — immediate hard delete
67+
- ``'7d'`` — prune after 7 days (default)
68+
- ``'24h'`` — prune after 24 hours
69+
- ``'30m'`` — prune after 30 minutes
70+
"""
71+
return client.delete(
72+
"/datasets/{datasetId}",
73+
params={"when": when},
74+
datasetId=dataset_id,
75+
)
6076

6177

6278
def list_datasets(
@@ -141,3 +157,28 @@ def get_unpublished(
141157
"/datasets/unpublished",
142158
params={"page": page, "pageSize": page_size},
143159
)
160+
161+
162+
def undelete_dataset(client: CloudClient, dataset_id: str) -> dict[str, Any]:
163+
"""POST /datasets/{datasetId}/undelete
164+
165+
Reverse a deferred (soft) delete before the pruner runs.
166+
Raises :class:`~ndi.cloud.exceptions.CloudAPIError` if the dataset
167+
has already been permanently deleted.
168+
"""
169+
return client.post("/datasets/{datasetId}/undelete", datasetId=dataset_id)
170+
171+
172+
def list_deleted_datasets(
173+
client: CloudClient,
174+
page: int = 1,
175+
page_size: int = 1000,
176+
) -> dict[str, Any]:
177+
"""GET /datasets/deleted?page=&pageSize=
178+
179+
Returns soft-deleted datasets that have not yet been pruned.
180+
"""
181+
return client.get(
182+
"/datasets/deleted",
183+
params={"page": page, "pageSize": page_size},
184+
)

src/ndi/cloud/api/documents.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,19 @@ def delete_document(
5858
client: CloudClient,
5959
dataset_id: str,
6060
document_id: str,
61-
) -> bool:
62-
"""DELETE /datasets/{datasetId}/documents/{documentId}"""
63-
client.delete(
61+
when: str = "7d",
62+
) -> dict[str, Any]:
63+
"""DELETE /datasets/{datasetId}/documents/{documentId}?when=...
64+
65+
Soft-delete a document. See :func:`~ndi.cloud.api.datasets.delete_dataset`
66+
for the *when* parameter format.
67+
"""
68+
return client.delete(
6469
"/datasets/{datasetId}/documents/{documentId}",
70+
params={"when": when},
6571
datasetId=dataset_id,
6672
documentId=document_id,
6773
)
68-
return True
6974

7075

7176
def list_documents(
@@ -179,11 +184,17 @@ def bulk_delete(
179184
client: CloudClient,
180185
dataset_id: str,
181186
doc_ids: list[str],
187+
when: str = "7d",
182188
) -> dict[str, Any]:
183-
"""POST /datasets/{datasetId}/documents/bulk-delete"""
189+
"""POST /datasets/{datasetId}/documents/bulk-delete
190+
191+
Soft-delete multiple documents. See
192+
:func:`~ndi.cloud.api.datasets.delete_dataset` for the *when*
193+
parameter format.
194+
"""
184195
return client.post(
185196
"/datasets/{datasetId}/documents/bulk-delete",
186-
json={"documentIds": doc_ids},
197+
json={"documentIds": doc_ids, "when": when},
187198
datasetId=dataset_id,
188199
)
189200

@@ -238,6 +249,23 @@ def ndi_query_all(
238249
return all_docs
239250

240251

252+
def list_deleted_documents(
253+
client: CloudClient,
254+
dataset_id: str,
255+
page: int = 1,
256+
page_size: int = 1000,
257+
) -> dict[str, Any]:
258+
"""GET /datasets/{datasetId}/documents/deleted?page=&pageSize=
259+
260+
Returns soft-deleted documents that have not yet been pruned.
261+
"""
262+
return client.get(
263+
"/datasets/{datasetId}/documents/deleted",
264+
params={"page": page, "pageSize": page_size},
265+
datasetId=dataset_id,
266+
)
267+
268+
241269
def add_document_as_file(
242270
client: CloudClient,
243271
dataset_id: str,

src/ndi/cloud/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,9 @@ def put(
7878
"""HTTP PUT."""
7979
return self._request("PUT", endpoint, json=json, data=data, **path_params)
8080

81-
def delete(self, endpoint: str, **path_params: str) -> Any:
81+
def delete(self, endpoint: str, params: dict | None = None, **path_params: str) -> Any:
8282
"""HTTP DELETE."""
83-
return self._request("DELETE", endpoint, **path_params)
83+
return self._request("DELETE", endpoint, params=params, **path_params)
8484

8585
# ------------------------------------------------------------------
8686
# Internal

tests/test_cloud_live.py

Lines changed: 194 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def can_write(client, cloud_config):
151151
ds_id = result.get("_id", result.get("id", ""))
152152
if ds_id:
153153
try:
154-
delete_dataset(client, ds_id)
154+
delete_dataset(client, ds_id, when="now")
155155
except Exception:
156156
pass
157157
return True
@@ -183,7 +183,7 @@ def fresh_dataset(client, cloud_config, can_write):
183183

184184
# Teardown: delete the dataset
185185
try:
186-
delete_dataset(client, dataset_id)
186+
delete_dataset(client, dataset_id, when="now")
187187
except Exception:
188188
pass
189189

@@ -364,7 +364,7 @@ def test_create_and_delete_dataset(self, client, cloud_config, can_write):
364364
assert ds.get("_id") == ds_id or ds.get("id") == ds_id
365365
finally:
366366
try:
367-
_retry_on_server_error(lambda: delete_dataset(client, ds_id))
367+
_retry_on_server_error(lambda: delete_dataset(client, ds_id, when="now"))
368368
except Exception:
369369
pass # Best-effort cleanup
370370

@@ -463,7 +463,7 @@ def test_add_get_delete_document(self, client, fresh_dataset):
463463
assert fetched.get("base", {}).get("name") == "test_document"
464464

465465
# Delete
466-
delete_document(client, fresh_dataset, doc_id)
466+
delete_document(client, fresh_dataset, doc_id, when="now")
467467

468468
# Verify gone
469469
from ndi.cloud.exceptions import CloudAPIError
@@ -499,7 +499,7 @@ def test_update_document(self, client, fresh_dataset):
499499
assert fetched.get("base", {}).get("name") == "modified"
500500
finally:
501501
try:
502-
delete_document(client, fresh_dataset, doc_id)
502+
delete_document(client, fresh_dataset, doc_id, when="now")
503503
except Exception:
504504
pass
505505

@@ -617,7 +617,7 @@ def test_bulk_delete(self, client, fresh_dataset):
617617
doc_ids.append(result.get("_id", result.get("id", "")))
618618

619619
# Delete the first 3
620-
bulk_delete(client, fresh_dataset, doc_ids[:3])
620+
bulk_delete(client, fresh_dataset, doc_ids[:3], when="now")
621621

622622
# Small delay for server processing
623623
time.sleep(2)
@@ -873,6 +873,194 @@ def test_unpublished_datasets_list(self, client):
873873
assert isinstance(result, dict)
874874

875875

876+
# ===========================================================================
877+
# TestSoftDelete -- soft-delete, undelete, list-deleted (requires write)
878+
# ===========================================================================
879+
880+
881+
class TestSoftDelete:
882+
"""Soft-delete API: deferred delete, undelete, and list-deleted."""
883+
884+
def test_deferred_delete_and_undelete(self, client, cloud_config, can_write):
885+
"""Delete with when='7d', verify listed as deleted, then undelete.
886+
887+
Creates a dataset WITH documents to mimic real-world usage.
888+
"""
889+
if not can_write:
890+
pytest.skip("User does not have dataset creation privileges")
891+
892+
from ndi.cloud.api.datasets import (
893+
create_dataset,
894+
delete_dataset,
895+
get_dataset,
896+
list_deleted_datasets,
897+
undelete_dataset,
898+
)
899+
from ndi.cloud.api.documents import add_document, list_all_documents
900+
from ndi.cloud.exceptions import CloudAPIError as _APIError
901+
902+
org_id = cloud_config.org_id
903+
try:
904+
result = _retry_on_server_error(
905+
lambda: create_dataset(client, org_id, "NDI_PYTEST_SOFT_DELETE")
906+
)
907+
except _APIError as exc:
908+
pytest.skip(f"create_dataset timed out: {exc}")
909+
ds_id = result.get("_id", result.get("id", ""))
910+
assert ds_id
911+
912+
try:
913+
# Add documents to make it realistic
914+
for i in range(3):
915+
add_document(
916+
client,
917+
ds_id,
918+
{
919+
"document_class": {"class_name": "ndi_pytest_softdel"},
920+
"base": {"name": f"softdel_doc_{i}"},
921+
},
922+
)
923+
924+
# Deferred delete (7 days)
925+
del_result = delete_dataset(client, ds_id, when="7d")
926+
assert isinstance(del_result, dict)
927+
assert "message" in del_result
928+
929+
# Should appear in deleted list
930+
time.sleep(2)
931+
deleted = list_deleted_datasets(client)
932+
deleted_ids = {d.get("_id", d.get("id", "")) for d in deleted.get("datasets", [])}
933+
assert ds_id in deleted_ids, f"Dataset {ds_id} not found in deleted list"
934+
935+
# Undelete
936+
undelete_result = undelete_dataset(client, ds_id)
937+
assert isinstance(undelete_result, dict)
938+
939+
# Should be accessible again with documents intact
940+
time.sleep(2)
941+
ds = _retry_on_server_error(lambda: get_dataset(client, ds_id), retry_on_404=True)
942+
ds_fetched_id = ds.get("_id", ds.get("id", ""))
943+
assert ds_fetched_id == ds_id
944+
945+
# Verify documents survived the soft-delete round-trip
946+
docs = list_all_documents(client, ds_id)
947+
assert len(docs) >= 3, f"Expected >= 3 docs after undelete, got {len(docs)}"
948+
finally:
949+
# Final cleanup
950+
try:
951+
delete_dataset(client, ds_id, when="now")
952+
except Exception:
953+
pass
954+
955+
def test_immediate_delete_cannot_undelete(self, client, cloud_config, can_write):
956+
"""Delete with when='now' — undelete 10s later should fail.
957+
958+
Creates a dataset WITH documents to mimic real-world usage.
959+
"""
960+
if not can_write:
961+
pytest.skip("User does not have dataset creation privileges")
962+
963+
from ndi.cloud.api.datasets import (
964+
create_dataset,
965+
delete_dataset,
966+
undelete_dataset,
967+
)
968+
from ndi.cloud.api.documents import add_document
969+
from ndi.cloud.exceptions import CloudAPIError as _APIError
970+
971+
org_id = cloud_config.org_id
972+
try:
973+
result = _retry_on_server_error(
974+
lambda: create_dataset(client, org_id, "NDI_PYTEST_HARD_DELETE")
975+
)
976+
except _APIError as exc:
977+
pytest.skip(f"create_dataset timed out: {exc}")
978+
ds_id = result.get("_id", result.get("id", ""))
979+
assert ds_id
980+
981+
# Add documents to make it realistic
982+
for i in range(3):
983+
add_document(
984+
client,
985+
ds_id,
986+
{
987+
"document_class": {"class_name": "ndi_pytest_harddel"},
988+
"base": {"name": f"harddel_doc_{i}"},
989+
},
990+
)
991+
992+
# Immediate delete
993+
delete_dataset(client, ds_id, when="now")
994+
time.sleep(10)
995+
996+
# Undelete should fail — dataset is permanently gone
997+
with pytest.raises(_APIError):
998+
undelete_dataset(client, ds_id)
999+
1000+
def test_list_deleted_documents(self, client, fresh_dataset):
1001+
"""Add doc, delete it, verify it appears in deleted-documents list."""
1002+
from ndi.cloud.api.documents import (
1003+
add_document,
1004+
delete_document,
1005+
list_deleted_documents,
1006+
)
1007+
1008+
doc_json = {
1009+
"document_class": {"class_name": "ndi_pytest_softdel"},
1010+
"base": {"name": "soft_delete_test"},
1011+
}
1012+
result = add_document(client, fresh_dataset, doc_json)
1013+
doc_id = result.get("_id", result.get("id", ""))
1014+
assert doc_id
1015+
1016+
delete_document(client, fresh_dataset, doc_id, when="now")
1017+
time.sleep(2)
1018+
1019+
deleted = list_deleted_documents(client, fresh_dataset)
1020+
assert isinstance(deleted, dict)
1021+
# The response should have a documents list
1022+
deleted_docs = deleted.get("documents", [])
1023+
assert isinstance(deleted_docs, list)
1024+
1025+
def test_delete_dataset_returns_message(self, client, cloud_config, can_write):
1026+
"""delete_dataset should return a response dict with a message."""
1027+
if not can_write:
1028+
pytest.skip("User does not have dataset creation privileges")
1029+
1030+
from ndi.cloud.api.datasets import create_dataset, delete_dataset
1031+
from ndi.cloud.exceptions import CloudAPIError as _APIError
1032+
1033+
org_id = cloud_config.org_id
1034+
try:
1035+
result = _retry_on_server_error(
1036+
lambda: create_dataset(client, org_id, "NDI_PYTEST_DEL_MSG")
1037+
)
1038+
except _APIError as exc:
1039+
pytest.skip(f"create_dataset timed out: {exc}")
1040+
ds_id = result.get("_id", result.get("id", ""))
1041+
assert ds_id
1042+
1043+
del_result = delete_dataset(client, ds_id, when="now")
1044+
assert isinstance(del_result, dict)
1045+
assert "message" in del_result
1046+
1047+
def test_delete_document_returns_message(self, client, fresh_dataset):
1048+
"""delete_document should return a response dict with a message."""
1049+
from ndi.cloud.api.documents import add_document, delete_document
1050+
1051+
doc_json = {
1052+
"document_class": {"class_name": "ndi_pytest_delmsg"},
1053+
"base": {"name": "delete_msg_test"},
1054+
}
1055+
result = add_document(client, fresh_dataset, doc_json)
1056+
doc_id = result.get("_id", result.get("id", ""))
1057+
assert doc_id
1058+
1059+
del_result = delete_document(client, fresh_dataset, doc_id, when="now")
1060+
assert isinstance(del_result, dict)
1061+
assert "message" in del_result
1062+
1063+
8761064
# ===========================================================================
8771065
# TestErrorHandling -- replaces mocked error tests
8781066
# ===========================================================================

0 commit comments

Comments
 (0)