Skip to content

Commit f881650

Browse files
committed
feat: add WorkersResource + UsageResource (parity with /v1/workers + /v1/usage)
Closes 2 entries from cueapi-python #24's `endpoints_missing` parity manifest: - GET /v1/workers → client.workers.list() - DELETE /v1/workers/{id} → client.workers.delete(worker_id) - GET /v1/usage → client.usage.get() (`DELETE /v1/workers/{id}` wasn't in the manifest but is part of the same hosted surface — added for completeness.) New resource classes: - `cueapi/resources/workers.py`: WorkersResource — `.list()` + `.delete()` - `cueapi/resources/usage.py`: UsageResource — `.get()` Both registered on the CueAPI client and exported from cueapi.__init__. Skipped from manifest: POST /v1/worker/heartbeat (worker registration). The hosted endpoint is meant for cueapi-worker (which already wraps it correctly with heartbeat-loop semantics); direct SDK-driven registration is redundant. Documented in WorkersResource's class docstring. Tests: 5 new (12 → 17 unit tests). Mock-based, mirrors the existing ExecutionsResource test pattern. The 14 pre-existing staging-cred test_cues.py failures (`ValueError: api_key is required`) are unrelated to this PR — same flake captured in the Backlog row added when surveying cueapi-python earlier this session. No hosted-PR dependency. All 3 endpoints already shipped on prod. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent ac957e2 commit f881650

6 files changed

Lines changed: 179 additions & 0 deletions

File tree

cueapi/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
)
1313
from cueapi.payload import CuePayload
1414
from cueapi.resources.executions import ExecutionsResource
15+
from cueapi.resources.usage import UsageResource
16+
from cueapi.resources.workers import WorkersResource
1517
from cueapi.webhook import verify_webhook
1618

1719
__version__ = "0.1.2"
@@ -20,6 +22,8 @@
2022
"CueAPI",
2123
"CuePayload",
2224
"ExecutionsResource",
25+
"UsageResource",
26+
"WorkersResource",
2327
"verify_webhook",
2428
"CueAPIError",
2529
"AuthenticationError",

cueapi/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
)
1818
from cueapi.resources.cues import CuesResource
1919
from cueapi.resources.executions import ExecutionsResource
20+
from cueapi.resources.usage import UsageResource
21+
from cueapi.resources.workers import WorkersResource
2022

2123
DEFAULT_BASE_URL = "https://api.cueapi.ai"
2224
DEFAULT_TIMEOUT = 30.0
@@ -69,6 +71,8 @@ def __init__(
6971
# Resources
7072
self.cues = CuesResource(self)
7173
self.executions = ExecutionsResource(self)
74+
self.workers = WorkersResource(self)
75+
self.usage = UsageResource(self)
7276

7377
def close(self) -> None:
7478
"""Close the underlying HTTP client."""

cueapi/resources/usage.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Usage resource — plan + cue + execution usage stats."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from cueapi.client import CueAPI
9+
10+
11+
class UsageResource:
12+
"""Usage stats resource.
13+
14+
Wraps ``GET /v1/usage`` for SDK callers who want plan + cue count +
15+
execution count + rate-limit info without parsing the broader
16+
``/v1/auth/me`` response.
17+
"""
18+
19+
def __init__(self, client: "CueAPI") -> None:
20+
self._client = client
21+
22+
def get(self) -> dict:
23+
"""Get current usage stats.
24+
25+
Returns:
26+
Dict with ``plan`` (name + interval + period_end),
27+
``cues`` (active count + limit),
28+
``executions`` (used this period + limit + outcomes summary),
29+
and ``rate_limit`` (requests/min limit).
30+
"""
31+
return self._client._get("/v1/usage")

cueapi/resources/workers.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Workers resource — fleet visibility for worker-transport users."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from cueapi.client import CueAPI
9+
10+
11+
class WorkersResource:
12+
"""Workers API resource.
13+
14+
Mirrors the hosted ``/v1/workers`` surface — list registered workers
15+
with heartbeat status, and delete decommissioned workers. Worker
16+
registration itself happens via cueapi-worker (which sends heartbeats);
17+
the SDK doesn't expose ``POST /v1/worker/heartbeat`` because direct
18+
SDK-driven registration is redundant with that package.
19+
"""
20+
21+
def __init__(self, client: "CueAPI") -> None:
22+
self._client = client
23+
24+
def list(self) -> dict:
25+
"""List all registered workers with heartbeat status.
26+
27+
Returns:
28+
Dict with ``workers`` (list of worker dicts) and ``total``.
29+
Each worker carries ``worker_id``, ``handlers``,
30+
``last_heartbeat``, ``heartbeat_status``
31+
(``online`` / ``stale`` / ``dead`` based on seconds since
32+
last heartbeat), and ``seconds_since_heartbeat``.
33+
"""
34+
return self._client._get("/v1/workers")
35+
36+
def delete(self, worker_id: str) -> None:
37+
"""Delete a registered worker.
38+
39+
Removes the worker row; in-flight executions claimed by this
40+
worker will be picked up by the stale-recovery loop. Useful for
41+
cleaning up workers that have been decommissioned.
42+
43+
Returns ``None`` on success (HTTP 204). Raises
44+
``CueNotFoundError`` if the worker doesn't exist.
45+
46+
Args:
47+
worker_id: The caller-defined worker_id used during the
48+
worker's heartbeats. Same value is what appears in
49+
``list()`` responses.
50+
"""
51+
return self._client._delete(f"/v1/workers/{worker_id}")

tests/test_usage_resource.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Tests for UsageResource."""
2+
3+
from unittest.mock import MagicMock
4+
5+
from cueapi.resources.usage import UsageResource
6+
7+
8+
class TestGet:
9+
def test_get_calls_get_usage(self):
10+
mock_client = MagicMock()
11+
mock_client._get.return_value = {
12+
"plan": {"name": "pro", "interval": "monthly"},
13+
"cues": {"active": 12, "limit": 100},
14+
"executions": {"used": 543, "limit": 5000},
15+
"rate_limit": {"limit": 200},
16+
}
17+
resource = UsageResource(mock_client)
18+
19+
result = resource.get()
20+
21+
mock_client._get.assert_called_once_with("/v1/usage")
22+
assert result["plan"]["name"] == "pro"
23+
assert result["cues"]["active"] == 12
24+
25+
def test_get_returns_server_dict_unchanged(self):
26+
# Pin the no-transform behavior so a future refactor can't
27+
# silently start coercing the response into a typed object
28+
# without bumping the major version.
29+
mock_client = MagicMock()
30+
mock_client._get.return_value = {"unexpected_field": "value"}
31+
resource = UsageResource(mock_client)
32+
33+
result = resource.get()
34+
assert result == {"unexpected_field": "value"}

tests/test_workers_resource.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Tests for WorkersResource."""
2+
3+
from unittest.mock import MagicMock
4+
5+
from cueapi.resources.workers import WorkersResource
6+
7+
8+
class TestList:
9+
def test_list_calls_get_workers(self):
10+
mock_client = MagicMock()
11+
mock_client._get.return_value = {
12+
"workers": [
13+
{
14+
"worker_id": "worker-1",
15+
"handlers": ["task-a"],
16+
"last_heartbeat": "2026-05-04T17:30:00Z",
17+
"heartbeat_status": "online",
18+
"seconds_since_heartbeat": 5,
19+
}
20+
],
21+
"total": 1,
22+
}
23+
resource = WorkersResource(mock_client)
24+
25+
result = resource.list()
26+
27+
mock_client._get.assert_called_once_with("/v1/workers")
28+
assert result["total"] == 1
29+
assert result["workers"][0]["worker_id"] == "worker-1"
30+
31+
def test_list_passes_no_params(self):
32+
# Endpoint accepts no query params; SDK MUST NOT silently start
33+
# passing params (would couple to a future server-side change).
34+
# Pinning the bare-call shape.
35+
mock_client = MagicMock()
36+
mock_client._get.return_value = {"workers": [], "total": 0}
37+
resource = WorkersResource(mock_client)
38+
39+
resource.list()
40+
41+
mock_client._get.assert_called_once_with("/v1/workers")
42+
# No params kwarg.
43+
assert "params" not in mock_client._get.call_args.kwargs
44+
45+
46+
class TestDelete:
47+
def test_delete_calls_delete_workers_id(self):
48+
mock_client = MagicMock()
49+
mock_client._delete.return_value = None # 204 -> None per client _request
50+
resource = WorkersResource(mock_client)
51+
52+
result = resource.delete("worker-1")
53+
54+
mock_client._delete.assert_called_once_with("/v1/workers/worker-1")
55+
assert result is None

0 commit comments

Comments
 (0)