Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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

## [Unreleased]

### Added

- `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.

## [0.2.0] - 2026-05-01

### Added
Expand Down
45 changes: 45 additions & 0 deletions cueapi/resources/cues.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,51 @@ def delete(self, cue_id: str) -> None:
"""
self._client._delete(f"/v1/cues/{cue_id}")

def bulk_delete(self, ids: List[str]) -> Dict[str, List[str]]:
"""Delete multiple cues in a single call (max 100 per call).

Per-ID atomic, NOT batch atomic — each ID is independently checked
for caller ownership. IDs that don't exist OR aren't owned by the
caller land in the ``skipped`` array (silent skip on miss; no
info leak about other tenants' cues). Cascade FK handles
executions + dispatch_outbox cleanup.

Sends the ``X-Confirm-Destructive: true`` header automatically;
the substrate requires it for any bulk-destructive endpoint.

Args:
ids: Cue IDs to delete. Length 1-100. Server enforces the cap;
this method also validates client-side to fail fast.

Returns:
A dict shaped::

{
"deleted": ["cue_abc", "cue_def"], # IDs whose rows are gone
"skipped": ["cue_xyz"] # IDs that didn't exist or weren't owned
}

Order within each group preserves the request's ``ids`` array.

Raises:
ValueError: If ``ids`` is empty or has more than 100 entries.

Example:
>>> client.cues.bulk_delete(["cue_abc", "cue_def", "cue_xyz"])
{"deleted": ["cue_abc", "cue_def"], "skipped": ["cue_xyz"]}
"""
if not ids:
raise ValueError("ids must contain at least one cue ID.")
if len(ids) > 100:
raise ValueError(
f"Max 100 IDs per call; got {len(ids)}. Split into batches."
)
return self._client._post(
"/v1/cues/bulk-delete",
json={"ids": list(ids)},
headers={"X-Confirm-Destructive": "true"},
)

def pause(self, cue_id: str) -> Cue:
"""Pause a cue. Equivalent to update(cue_id, status="paused").

Expand Down
97 changes: 97 additions & 0 deletions tests/test_cues_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,100 @@ def test_fire_omits_merge_strategy_when_not_passed(self):

sent_body = mock_client._post.call_args.kwargs["json"]
assert "merge_strategy" not in sent_body


class TestBulkDelete:
def test_bulk_delete_happy_path(self):
mock_client = MagicMock()
mock_client._post.return_value = {
"deleted": ["cue_abc", "cue_def"],
"skipped": [],
}
resource = CuesResource(mock_client)

result = resource.bulk_delete(["cue_abc", "cue_def"])

mock_client._post.assert_called_once_with(
"/v1/cues/bulk-delete",
json={"ids": ["cue_abc", "cue_def"]},
headers={"X-Confirm-Destructive": "true"},
)
assert result == {"deleted": ["cue_abc", "cue_def"], "skipped": []}

def test_bulk_delete_with_skipped(self):
mock_client = MagicMock()
mock_client._post.return_value = {
"deleted": ["cue_abc"],
"skipped": ["cue_xyz"],
}
resource = CuesResource(mock_client)

result = resource.bulk_delete(["cue_abc", "cue_xyz"])

assert result["deleted"] == ["cue_abc"]
assert result["skipped"] == ["cue_xyz"]

def test_bulk_delete_sends_confirm_destructive_header(self):
# Pin the X-Confirm-Destructive header — server requires it for
# any bulk-destructive endpoint. If a future refactor drops this
# header, the server returns 400 confirmation_required and the
# SDK call silently fails.
mock_client = MagicMock()
mock_client._post.return_value = {"deleted": ["cue_a"], "skipped": []}
resource = CuesResource(mock_client)

resource.bulk_delete(["cue_a"])

kwargs = mock_client._post.call_args.kwargs
assert kwargs["headers"]["X-Confirm-Destructive"] == "true"

def test_bulk_delete_empty_ids_raises(self):
mock_client = MagicMock()
resource = CuesResource(mock_client)

import pytest

with pytest.raises(ValueError, match="at least one cue ID"):
resource.bulk_delete([])

# Server NOT called — fail-fast at SDK layer.
mock_client._post.assert_not_called()

def test_bulk_delete_over_100_ids_raises(self):
mock_client = MagicMock()
resource = CuesResource(mock_client)

import pytest

ids = [f"cue_{i}" for i in range(101)]
with pytest.raises(ValueError, match="Max 100"):
resource.bulk_delete(ids)

mock_client._post.assert_not_called()

def test_bulk_delete_exactly_100_ids_ok(self):
# Boundary — 100 IDs is allowed (server cap is inclusive).
mock_client = MagicMock()
mock_client._post.return_value = {
"deleted": [f"cue_{i}" for i in range(100)],
"skipped": [],
}
resource = CuesResource(mock_client)

ids = [f"cue_{i}" for i in range(100)]
result = resource.bulk_delete(ids)

assert len(result["deleted"]) == 100

def test_bulk_delete_accepts_iterable_not_just_list(self):
# The method coerces the input to a list before sending. Verifies
# tuple / generator inputs work without explicit conversion at
# the call site.
mock_client = MagicMock()
mock_client._post.return_value = {"deleted": ["cue_a"], "skipped": []}
resource = CuesResource(mock_client)

resource.bulk_delete(("cue_a",))

sent_body = mock_client._post.call_args.kwargs["json"]
assert sent_body == {"ids": ["cue_a"]}
Loading