Skip to content

Commit aa49e3f

Browse files
mikemolinetclaude
andcommitted
feat(cues): add bulk_delete (cueapi #650 / cli #46 parity)
Adds `client.cues.bulk_delete(ids)` — wraps POST /v1/cues/bulk-delete. Closes Backlog row cmousydyn (mistitled 'messages'; actually cues per PR #650 + cli #46; PM corrected title 2026-05-09 ~20:45Z). Returns {"deleted": [...], "skipped": [...]} — per-ID atomic, not batch atomic. IDs that don't exist OR aren't owned by the caller land in `skipped` (silent skip on miss; no info leak about other tenants' cues). Sends X-Confirm-Destructive: true header automatically (server requires it for any bulk-destructive endpoint, mirrors the agents-webhook-secret-regenerate + auth-key-regenerate pattern). Client-side validation: - Empty `ids` → ValueError "at least one cue ID" - > 100 IDs → ValueError "Max 100" (matches server cap; fail-fast) Tests: 7 new in TestBulkDelete class — happy path, with-skipped, header pin, empty-raises, over-100-raises, exactly-100-ok boundary, iterable-not-just-list. 11/11 pass in tests/test_cues_resource.py. Parity context: cueapi-cli #46 shipped this as `cueapi bulk-delete` on 2026-05-06. cueapi-mcp + cueapi-action ports follow as separate PRs in the same Backlog row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b30d23b commit aa49e3f

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
All notable changes to cueapi-sdk will be documented here.
44

5+
## [Unreleased]
6+
7+
### Added
8+
9+
- `client.cues.bulk_delete(ids)` — delete up to 100 cues in a single call. Returns `{"deleted": [...], "skipped": [...]}`. Per-ID atomic, not batch atomic. Sends `X-Confirm-Destructive: true` header automatically. Wraps `POST /v1/cues/bulk-delete` (cueapi #650). Parity port of cueapi-cli #46. Raises `ValueError` client-side on empty list or > 100 IDs.
10+
511
## [0.2.0] - 2026-05-01
612

713
### Added

cueapi/resources/cues.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,51 @@ def delete(self, cue_id: str) -> None:
197197
"""
198198
self._client._delete(f"/v1/cues/{cue_id}")
199199

200+
def bulk_delete(self, ids: List[str]) -> Dict[str, List[str]]:
201+
"""Delete multiple cues in a single call (max 100 per call).
202+
203+
Per-ID atomic, NOT batch atomic — each ID is independently checked
204+
for caller ownership. IDs that don't exist OR aren't owned by the
205+
caller land in the ``skipped`` array (silent skip on miss; no
206+
info leak about other tenants' cues). Cascade FK handles
207+
executions + dispatch_outbox cleanup.
208+
209+
Sends the ``X-Confirm-Destructive: true`` header automatically;
210+
the substrate requires it for any bulk-destructive endpoint.
211+
212+
Args:
213+
ids: Cue IDs to delete. Length 1-100. Server enforces the cap;
214+
this method also validates client-side to fail fast.
215+
216+
Returns:
217+
A dict shaped::
218+
219+
{
220+
"deleted": ["cue_abc", "cue_def"], # IDs whose rows are gone
221+
"skipped": ["cue_xyz"] # IDs that didn't exist or weren't owned
222+
}
223+
224+
Order within each group preserves the request's ``ids`` array.
225+
226+
Raises:
227+
ValueError: If ``ids`` is empty or has more than 100 entries.
228+
229+
Example:
230+
>>> client.cues.bulk_delete(["cue_abc", "cue_def", "cue_xyz"])
231+
{"deleted": ["cue_abc", "cue_def"], "skipped": ["cue_xyz"]}
232+
"""
233+
if not ids:
234+
raise ValueError("ids must contain at least one cue ID.")
235+
if len(ids) > 100:
236+
raise ValueError(
237+
f"Max 100 IDs per call; got {len(ids)}. Split into batches."
238+
)
239+
return self._client._post(
240+
"/v1/cues/bulk-delete",
241+
json={"ids": list(ids)},
242+
headers={"X-Confirm-Destructive": "true"},
243+
)
244+
200245
def pause(self, cue_id: str) -> Cue:
201246
"""Pause a cue. Equivalent to update(cue_id, status="paused").
202247

tests/test_cues_resource.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,100 @@ def test_fire_omits_merge_strategy_when_not_passed(self):
5555

5656
sent_body = mock_client._post.call_args.kwargs["json"]
5757
assert "merge_strategy" not in sent_body
58+
59+
60+
class TestBulkDelete:
61+
def test_bulk_delete_happy_path(self):
62+
mock_client = MagicMock()
63+
mock_client._post.return_value = {
64+
"deleted": ["cue_abc", "cue_def"],
65+
"skipped": [],
66+
}
67+
resource = CuesResource(mock_client)
68+
69+
result = resource.bulk_delete(["cue_abc", "cue_def"])
70+
71+
mock_client._post.assert_called_once_with(
72+
"/v1/cues/bulk-delete",
73+
json={"ids": ["cue_abc", "cue_def"]},
74+
headers={"X-Confirm-Destructive": "true"},
75+
)
76+
assert result == {"deleted": ["cue_abc", "cue_def"], "skipped": []}
77+
78+
def test_bulk_delete_with_skipped(self):
79+
mock_client = MagicMock()
80+
mock_client._post.return_value = {
81+
"deleted": ["cue_abc"],
82+
"skipped": ["cue_xyz"],
83+
}
84+
resource = CuesResource(mock_client)
85+
86+
result = resource.bulk_delete(["cue_abc", "cue_xyz"])
87+
88+
assert result["deleted"] == ["cue_abc"]
89+
assert result["skipped"] == ["cue_xyz"]
90+
91+
def test_bulk_delete_sends_confirm_destructive_header(self):
92+
# Pin the X-Confirm-Destructive header — server requires it for
93+
# any bulk-destructive endpoint. If a future refactor drops this
94+
# header, the server returns 400 confirmation_required and the
95+
# SDK call silently fails.
96+
mock_client = MagicMock()
97+
mock_client._post.return_value = {"deleted": ["cue_a"], "skipped": []}
98+
resource = CuesResource(mock_client)
99+
100+
resource.bulk_delete(["cue_a"])
101+
102+
kwargs = mock_client._post.call_args.kwargs
103+
assert kwargs["headers"]["X-Confirm-Destructive"] == "true"
104+
105+
def test_bulk_delete_empty_ids_raises(self):
106+
mock_client = MagicMock()
107+
resource = CuesResource(mock_client)
108+
109+
import pytest
110+
111+
with pytest.raises(ValueError, match="at least one cue ID"):
112+
resource.bulk_delete([])
113+
114+
# Server NOT called — fail-fast at SDK layer.
115+
mock_client._post.assert_not_called()
116+
117+
def test_bulk_delete_over_100_ids_raises(self):
118+
mock_client = MagicMock()
119+
resource = CuesResource(mock_client)
120+
121+
import pytest
122+
123+
ids = [f"cue_{i}" for i in range(101)]
124+
with pytest.raises(ValueError, match="Max 100"):
125+
resource.bulk_delete(ids)
126+
127+
mock_client._post.assert_not_called()
128+
129+
def test_bulk_delete_exactly_100_ids_ok(self):
130+
# Boundary — 100 IDs is allowed (server cap is inclusive).
131+
mock_client = MagicMock()
132+
mock_client._post.return_value = {
133+
"deleted": [f"cue_{i}" for i in range(100)],
134+
"skipped": [],
135+
}
136+
resource = CuesResource(mock_client)
137+
138+
ids = [f"cue_{i}" for i in range(100)]
139+
result = resource.bulk_delete(ids)
140+
141+
assert len(result["deleted"]) == 100
142+
143+
def test_bulk_delete_accepts_iterable_not_just_list(self):
144+
# The method coerces the input to a list before sending. Verifies
145+
# tuple / generator inputs work without explicit conversion at
146+
# the call site.
147+
mock_client = MagicMock()
148+
mock_client._post.return_value = {"deleted": ["cue_a"], "skipped": []}
149+
resource = CuesResource(mock_client)
150+
151+
resource.bulk_delete(("cue_a",))
152+
153+
sent_body = mock_client._post.call_args.kwargs["json"]
154+
assert sent_body == {"ids": ["cue_a"]}

0 commit comments

Comments
 (0)