diff --git a/cueapi/__init__.py b/cueapi/__init__.py index 5d60b4d..9c6fa22 100644 --- a/cueapi/__init__.py +++ b/cueapi/__init__.py @@ -10,6 +10,16 @@ InvalidScheduleError, RateLimitError, ) +from cueapi.models.agent import Agent, AgentList +from cueapi.models.cue import Cue, CueList +from cueapi.models.execution import Execution, ExecutionList, OutcomeDetail +from cueapi.models.message import ( + FromAgentRef, + Message, + MessageList, + StateTransitionResponse, +) +from cueapi.models.worker import Worker, WorkerList from cueapi.payload import CuePayload from cueapi.resources.agents import AgentsResource from cueapi.resources.executions import ExecutionsResource @@ -21,12 +31,25 @@ __version__ = "0.2.0" __all__ = [ + "Agent", + "AgentList", "AgentsResource", + "Cue", "CueAPI", + "CueList", "CuePayload", + "Execution", + "ExecutionList", "ExecutionsResource", + "FromAgentRef", + "Message", + "MessageList", "MessagesResource", + "OutcomeDetail", + "StateTransitionResponse", "UsageResource", + "Worker", + "WorkerList", "WorkersResource", "verify_webhook", "CueAPIError", diff --git a/cueapi/models/agent.py b/cueapi/models/agent.py new file mode 100644 index 0000000..1475bb8 --- /dev/null +++ b/cueapi/models/agent.py @@ -0,0 +1,49 @@ +"""Agent Pydantic model — typed accessor for messaging-primitive agent responses. + +Closes the Agent portion of cueapi-python #24's `model_drift` manifest. +``AgentsResource`` methods currently return raw dicts; callers can opt +into typed accessors via ``Agent.model_validate(client.agents.get(ref))`` +or ``AgentList.model_validate(client.agents.list())``. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + + +class Agent(BaseModel): + """Typed accessor for a messaging-primitive agent (Phase 12.1.5). + + ``webhook_secret`` is populated only on the response from + ``client.agents.create()`` (when ``webhook_url`` was supplied) and + from ``client.agents.webhook_secret_regenerate()``. Subsequent reads + omit the secret. + """ + + id: str + user_id: Optional[str] = None + api_key_id: Optional[str] = None + slug: str + display_name: str + webhook_url: Optional[str] = None + # One-time on create + on regenerate; None on subsequent reads. + webhook_secret: Optional[str] = None + metadata: Dict[str, Any] = {} + status: Optional[str] = None # online / offline / away + deleted_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + model_config = {"extra": "allow"} + + +class AgentList(BaseModel): + """Typed accessor for ``client.agents.list()`` responses.""" + + agents: List[Agent] + total: int + limit: int + offset: int + model_config = {"extra": "allow"} diff --git a/cueapi/models/execution.py b/cueapi/models/execution.py new file mode 100644 index 0000000..d3bc459 --- /dev/null +++ b/cueapi/models/execution.py @@ -0,0 +1,100 @@ +"""Execution Pydantic model — typed accessor for execution dict responses. + +Closes the Execution portion of cueapi-python #24's `model_drift` manifest. +``ExecutionsResource`` methods (`list`, `get`, `replay`) currently return +raw dicts; callers can opt into typed accessors via +``Execution.model_validate(client.executions.get(...))``. Returning the +typed object directly from resource methods is a separate breaking-change +PR (would bump major version). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class OutcomeDetail(BaseModel): + """Outcome reported by the worker / handler. Set when the execution + has reached a terminal state and the handler has reported via + ``POST /v1/executions/{id}/outcome``.""" + + success: bool + result: Optional[str] = None + error: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + recorded_at: Optional[datetime] = None + # Evidence fields (Phase 18 Gap 11 — outcome verification). + external_id: Optional[str] = None + result_url: Optional[str] = None + result_ref: Optional[str] = None + result_type: Optional[str] = None + summary: Optional[str] = None + artifacts: Optional[List[Any]] = None + validation_state: Optional[str] = None + assertions: Optional[Dict[str, Any]] = None + model_config = {"extra": "allow"} + + +class Execution(BaseModel): + """Typed accessor for an execution response. + + Mirrors the server's ``ExecutionResponse`` schema. Use as + ``Execution.model_validate(client.executions.get(exec_id))`` or + ``Execution.model_validate(item)`` over each item in + ``client.executions.list()['executions']``. + """ + + id: str + cue_id: str + scheduled_for: datetime + status: str + http_status: Optional[int] = None + response_body: Optional[str] = None + attempts: Optional[int] = None + next_retry: Optional[datetime] = None + error_message: Optional[str] = None + started_at: Optional[datetime] = None + delivered_at: Optional[datetime] = None + last_attempt_at: Optional[datetime] = None + claimed_by_worker: Optional[str] = None + claimed_at: Optional[datetime] = None + last_heartbeat_at: Optional[datetime] = None + # Hosted PR #589: effective payload the handler/webhook saw at delivery. + # `payload_override` if set on the execution, else parent cue's payload. + payload: Optional[Dict[str, Any]] = None + # Outcome — populated only after handler reports. + outcome: Optional[OutcomeDetail] = None + outcome_state: Optional[str] = Field( + default=None, + description=( + "Phase 18 Gap 11: enum tracking outcome verification state. " + "Values: reported_success / reported_failure / verified_success / " + "verification_pending / verification_failed / unknown." + ), + ) + triggered_by: Optional[str] = Field( + default=None, + description="scheduled / manual_fire / chain / replay", + ) + # Chain support (Gap 1 — on_success_fire native chaining). + chain_parent_id: Optional[str] = None + chain_depth: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + # Forward-compat: server may grow the response over time without the + # SDK breaking. Same pattern as AlertConfig / VerificationConfig in + # the Cue model. + model_config = {"extra": "allow"} + + +class ExecutionList(BaseModel): + """Typed accessor for ``client.executions.list()`` responses.""" + + executions: List[Execution] + total: int + limit: int + offset: int + model_config = {"extra": "allow"} diff --git a/cueapi/models/message.py b/cueapi/models/message.py new file mode 100644 index 0000000..df53535 --- /dev/null +++ b/cueapi/models/message.py @@ -0,0 +1,76 @@ +"""Message Pydantic model — typed accessor for messaging-primitive message responses. + +Closes the Message portion of cueapi-python #24's `model_drift` manifest. +``MessagesResource`` methods currently return raw dicts; callers can opt +into typed accessors via ``Message.model_validate(client.messages.get(id))`` +or ``MessageList.model_validate(client.agents.inbox(ref))``. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class FromAgentRef(BaseModel): + """Inline agent reference rendered on incoming-message responses.""" + + agent_id: Optional[str] = None + slug: Optional[str] = None + model_config = {"extra": "allow"} + + +class StateTransitionResponse(BaseModel): + """Response shape for ``mark_read`` and ``ack`` endpoints.""" + + delivery_state: str + read_at: Optional[datetime] = None + acked_at: Optional[datetime] = None + model_config = {"extra": "allow"} + + +class Message(BaseModel): + """Typed accessor for a messaging-primitive message (Phase 12.1.5). + + Mirrors the server's ``MessageResponse`` schema. Both inbox-fetched + and sent-fetched messages use this shape; the ``from`` / ``to`` slots + capture sender / recipient regardless of perspective. + """ + + id: str + user_id: Optional[str] = None + # Sender — populated on inbox responses; may be self on sent responses. + # Pydantic treats `from` as a reserved keyword, but the server uses it + # in the response. Use alias for clean access via .from_agent. + from_agent: Optional[FromAgentRef] = Field(default=None, alias="from") + to: Optional[str] = None + body: Optional[str] = None + subject: Optional[str] = None + thread_id: Optional[str] = None + reply_to: Optional[str] = None + reply_to_agent: Optional[str] = None + priority: Optional[int] = None + expects_reply: Optional[bool] = None + delivery_state: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + expires_at: Optional[datetime] = None + queued_at: Optional[datetime] = None + delivered_at: Optional[datetime] = None + read_at: Optional[datetime] = None + acked_at: Optional[datetime] = None + failed_at: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + model_config = {"extra": "allow", "populate_by_name": True} + + +class MessageList(BaseModel): + """Typed accessor for inbox / sent responses (lists of messages).""" + + messages: List[Message] + total: Optional[int] = None + limit: Optional[int] = None + offset: Optional[int] = None + model_config = {"extra": "allow"} diff --git a/cueapi/models/worker.py b/cueapi/models/worker.py new file mode 100644 index 0000000..6ade154 --- /dev/null +++ b/cueapi/models/worker.py @@ -0,0 +1,44 @@ +"""Worker Pydantic model — typed accessor for worker dict responses. + +Closes the Worker portion of cueapi-python #24's `model_drift` manifest. +``WorkersResource.list()`` currently returns a raw dict; callers can opt +into typed accessors via ``Worker.model_validate(item)`` over each item +in ``client.workers.list()['workers']``. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, List, Optional + +from pydantic import BaseModel + + +class Worker(BaseModel): + """Typed accessor for a registered worker. + + Mirrors the server's ``Worker`` model. ``heartbeat_status`` is + computed server-side from ``seconds_since_heartbeat``: + - ``online``: <60s since last heartbeat + - ``stale``: 60-360s since last heartbeat + - ``dead``: >360s since last heartbeat + """ + + id: Optional[str] = None + user_id: Optional[str] = None + worker_id: str + handlers: Optional[List[str]] = None + last_heartbeat: Optional[datetime] = None + heartbeat_status: Optional[str] = None + seconds_since_heartbeat: Optional[int] = None + api_key_id: Optional[str] = None + created_at: Optional[datetime] = None + model_config = {"extra": "allow"} + + +class WorkerList(BaseModel): + """Typed accessor for ``client.workers.list()`` responses.""" + + workers: List[Worker] + total: Optional[int] = None + model_config = {"extra": "allow"} diff --git a/tests/test_additive_models.py b/tests/test_additive_models.py new file mode 100644 index 0000000..0f21efd --- /dev/null +++ b/tests/test_additive_models.py @@ -0,0 +1,262 @@ +"""Tests for additive Pydantic models — Execution / Worker / Agent / Message. + +These models close the remaining `model_drift` items in cueapi-python #24's +parity manifest. Resource methods still return raw dicts (additive only — +no breaking change to return types). Callers opt into typed accessors via +``Model.model_validate(dict)``. +""" + +from cueapi import ( + Agent, + AgentList, + Execution, + ExecutionList, + Message, + MessageList, + StateTransitionResponse, + Worker, + WorkerList, +) + + +# --- Execution --- + + +class TestExecution: + def test_minimal_response_parses(self): + ex = Execution.model_validate({ + "id": "exec_abc", + "cue_id": "cue_xyz", + "scheduled_for": "2026-05-04T17:00:00Z", + "status": "pending", + }) + assert ex.id == "exec_abc" + assert ex.status == "pending" + assert ex.outcome is None + assert ex.payload is None + assert ex.attempts is None + + def test_with_payload_field_589(self): + # Pin: hosted PR #589's `payload` field is surfaced. + ex = Execution.model_validate({ + "id": "exec_abc", + "cue_id": "cue_xyz", + "scheduled_for": "2026-05-04T17:00:00Z", + "status": "success", + "payload": {"task": "demo", "key": "value"}, + }) + assert ex.payload == {"task": "demo", "key": "value"} + + def test_with_outcome_detail(self): + ex = Execution.model_validate({ + "id": "exec_abc", + "cue_id": "cue_xyz", + "scheduled_for": "2026-05-04T17:00:00Z", + "status": "success", + "outcome": { + "success": True, + "result": "processed 42 records", + "external_id": "tweet:123", + "result_url": "https://twitter.com/user/123", + "result_type": "tweet", + }, + "outcome_state": "verified_success", + }) + assert ex.outcome is not None + assert ex.outcome.success is True + assert ex.outcome.external_id == "tweet:123" + assert ex.outcome_state == "verified_success" + + def test_forward_compat_extra_field(self): + # Server may grow the response over time. Pin that unknown fields + # are kept (extra="allow") rather than dropped. + ex = Execution.model_validate({ + "id": "exec_abc", + "cue_id": "cue_xyz", + "scheduled_for": "2026-05-04T17:00:00Z", + "status": "pending", + "future_field_we_dont_know_about_yet": "value", + }) + assert ex.model_extra["future_field_we_dont_know_about_yet"] == "value" + + +class TestExecutionList: + def test_basic(self): + el = ExecutionList.model_validate({ + "executions": [ + { + "id": "exec_1", + "cue_id": "cue_x", + "scheduled_for": "2026-05-04T17:00:00Z", + "status": "success", + }, + { + "id": "exec_2", + "cue_id": "cue_x", + "scheduled_for": "2026-05-04T17:01:00Z", + "status": "pending", + }, + ], + "total": 2, + "limit": 20, + "offset": 0, + }) + assert len(el.executions) == 2 + assert el.executions[0].id == "exec_1" + assert el.executions[1].status == "pending" + + +# --- Worker --- + + +class TestWorker: + def test_basic(self): + w = Worker.model_validate({ + "worker_id": "worker-1", + "handlers": ["task-a", "task-b"], + "last_heartbeat": "2026-05-04T17:30:00Z", + "heartbeat_status": "online", + "seconds_since_heartbeat": 5, + }) + assert w.worker_id == "worker-1" + assert w.handlers == ["task-a", "task-b"] + assert w.heartbeat_status == "online" + + def test_minimal(self): + # Only worker_id is required; everything else is optional so older + # server responses still parse. + w = Worker.model_validate({"worker_id": "worker-1"}) + assert w.worker_id == "worker-1" + assert w.handlers is None + + +class TestWorkerList: + def test_basic(self): + wl = WorkerList.model_validate({ + "workers": [ + {"worker_id": "worker-1", "heartbeat_status": "online"}, + {"worker_id": "worker-2", "heartbeat_status": "stale"}, + ], + "total": 2, + }) + assert len(wl.workers) == 2 + assert wl.workers[0].heartbeat_status == "online" + + +# --- Agent --- + + +class TestAgent: + def test_minimal(self): + a = Agent.model_validate({ + "id": "agt_x", + "slug": "team-comm", + "display_name": "Team Comm", + }) + assert a.id == "agt_x" + assert a.webhook_url is None + assert a.webhook_secret is None + assert a.metadata == {} + + def test_with_webhook_secret_one_time(self): + # Server returns webhook_secret only on create + on regenerate. + a = Agent.model_validate({ + "id": "agt_x", + "slug": "team-comm", + "display_name": "Team Comm", + "webhook_url": "https://x.example/webhook", + "webhook_secret": "wsec_one_time_value", + }) + assert a.webhook_secret == "wsec_one_time_value" + + +class TestAgentList: + def test_basic(self): + al = AgentList.model_validate({ + "agents": [ + {"id": "agt_1", "slug": "a", "display_name": "A"}, + {"id": "agt_2", "slug": "b", "display_name": "B"}, + ], + "total": 2, + "limit": 50, + "offset": 0, + }) + assert len(al.agents) == 2 + + +# --- Message --- + + +class TestMessage: + def test_inbox_message_with_from_ref(self): + m = Message.model_validate({ + "id": "msg_x", + "from": {"agent_id": "agt_sender", "slug": "sender@x"}, + "to": "recipient@y", + "body": "hello", + "delivery_state": "delivered", + }) + assert m.id == "msg_x" + # Aliased: server's `from` → SDK's `from_agent`. + assert m.from_agent is not None + assert m.from_agent.slug == "sender@x" + assert m.delivery_state == "delivered" + + def test_with_thread_and_reply(self): + m = Message.model_validate({ + "id": "msg_reply", + "subject": "re: hello", + "body": "reply body", + "thread_id": "thr_abc", + "reply_to": "msg_original", + "priority": 4, + "expects_reply": True, + }) + assert m.thread_id == "thr_abc" + assert m.reply_to == "msg_original" + assert m.priority == 4 + assert m.expects_reply is True + + +class TestMessageList: + def test_basic(self): + ml = MessageList.model_validate({ + "messages": [ + {"id": "msg_1", "delivery_state": "queued"}, + {"id": "msg_2", "delivery_state": "delivered"}, + ], + "total": 2, + }) + assert len(ml.messages) == 2 + + +class TestStateTransitionResponse: + def test_read(self): + s = StateTransitionResponse.model_validate({ + "delivery_state": "read", + "read_at": "2026-05-04T17:00:00Z", + }) + assert s.delivery_state == "read" + assert s.acked_at is None + + def test_ack(self): + s = StateTransitionResponse.model_validate({ + "delivery_state": "acked", + "acked_at": "2026-05-04T17:01:00Z", + }) + assert s.delivery_state == "acked" + + +def test_all_models_exported_from_top_level(): + # Pin: every new model is importable from `cueapi` directly so callers + # don't have to know the internal path. + from pydantic import BaseModel + from cueapi import ( + Agent, AgentList, Execution, ExecutionList, FromAgentRef, + Message, MessageList, OutcomeDetail, StateTransitionResponse, + Worker, WorkerList, + ) + for cls in (Agent, AgentList, Execution, ExecutionList, FromAgentRef, + Message, MessageList, OutcomeDetail, StateTransitionResponse, + Worker, WorkerList): + assert issubclass(cls, BaseModel), f"{cls.__name__} not a BaseModel"