Skip to content

Commit cc55599

Browse files
authored
feat: expand Cue model with 8 missing fields (drift fix-up) (#29)
Closes the Cue portion of cueapi-python #24's `model_drift` manifest. The SDK's Cue Pydantic model was silently dropping 8 fields the server returns (Pydantic default extra="ignore"); callers reading e.g. the ``catch_up`` policy or ``stats`` blob via the SDK got nothing. Fields added (all Optional with defaults so legacy responses still parse): - delivery: Optional[DeliveryConfig] — timeout_seconds, outcome_deadline_seconds - alerts: Optional[AlertConfig] — extra="allow" forward-compat - catch_up: Optional[str] — run_once_if_missed / skip_missed / replay_all - verification: Optional[VerificationConfig] — mode + required_assertions; extra="allow" - on_success_fire: Optional[str] — cue ID for 1:1 chaining (Gap 1) - require_payload_override: bool = False — hosted PR #590; default False matches server - required_payload_keys: Optional[List[str]] — hosted PR #590 - stats: Optional[Dict[str, Any]] — CueDetailResponse-only blob (7d success rate etc.) Three new nested models: - DeliveryConfig: typed schema for the 2-phase delivery config - AlertConfig: forward-compat (extra="allow") since alert kinds evolve server-side - VerificationConfig: typed `mode` + `required_assertions` plus extra="allow" for forward-compat on assertion kinds Tests: 11 new (30 → 41 unit tests). Coverage: - Old response (without new fields) still parses cleanly — pinning backward compat - Each new field round-trips correctly with a realistic payload - AlertConfig forward-compat: unknown server-side keys land in model_extra without raising - VerificationConfig forward-compat: same - Full-response roundtrip with every field set - CueList parses correctly with new fields in each row No breaking change for SDK callers — fields are additive, all Optional/defaulted, server's prior shape still parses. Bump warranted at next minor (0.3.0) for the new accessor surface. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 346feca commit cc55599

2 files changed

Lines changed: 232 additions & 0 deletions

File tree

cueapi/models/cue.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,38 @@ class OnFailure(BaseModel):
3232
pause: bool = False
3333

3434

35+
class DeliveryConfig(BaseModel):
36+
"""Two-phase delivery configuration (Gap 5)."""
37+
38+
timeout_seconds: int = 30
39+
outcome_deadline_seconds: int = 300
40+
41+
42+
class AlertConfig(BaseModel):
43+
"""Alert configuration (Gap 5).
44+
45+
Surfaced as a passthrough dict via ``extra="allow"`` so callers see
46+
every field the server returns even if the SDK hasn't been updated
47+
for new alert kinds yet. Models that have grown additively benefit
48+
from forward-compat.
49+
"""
50+
51+
model_config = {"extra": "allow"}
52+
53+
54+
class VerificationConfig(BaseModel):
55+
"""Outcome verification policy.
56+
57+
The ``mode`` field controls evidence requirements. The
58+
``required_assertions`` field (Gap 8) controls structural requirements
59+
on the reported outcome.
60+
"""
61+
62+
mode: Optional[str] = None
63+
required_assertions: Optional[List[str]] = None
64+
model_config = {"extra": "allow"}
65+
66+
3567
class Cue(BaseModel):
3668
id: str
3769
name: str
@@ -47,6 +79,24 @@ class Cue(BaseModel):
4779
run_count: int = 0
4880
fired_count: int = 0
4981
on_failure: Optional[OnFailure] = None
82+
# Two-phase + alerts + catch-up + verification config (hosted Phase
83+
# 18 / Gap 5 / Gap 8). All optional and forward-compat — server
84+
# may grow these objects over time without breaking SDK callers.
85+
delivery: Optional[DeliveryConfig] = None
86+
alerts: Optional[AlertConfig] = None
87+
catch_up: Optional[str] = None
88+
verification: Optional[VerificationConfig] = None
89+
# On-success chaining (Gap 1): cue ID to fire when an execution of
90+
# this cue reaches a successful terminal state. Strictly 1:1.
91+
on_success_fire: Optional[str] = None
92+
# Per-cue payload_override enforcement on /fire (hosted PR #590).
93+
# Default false (server's default) so old responses without these
94+
# keys still parse cleanly.
95+
require_payload_override: bool = False
96+
required_payload_keys: Optional[List[str]] = None
97+
# Cue-detail-response stats: 7d success rate, miss rate, totals.
98+
# Returned only on GET /v1/cues/{id} detail; absent on list rows.
99+
stats: Optional[Dict[str, Any]] = None
50100
warning: Optional[str] = None
51101
created_at: datetime
52102
updated_at: datetime

tests/test_cue_model.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Unit tests for the Cue Pydantic model — drift-against-hosted-API coverage.
2+
3+
These tests validate that the Cue model deserializes the full server
4+
response shape, not just the subset the SDK had before the
5+
2026-05-04 fix-up. Run against synthesized payloads that mirror what
6+
the hosted ``app/schemas/cue.py CueResponse`` returns.
7+
"""
8+
9+
from datetime import datetime, timezone
10+
11+
from cueapi.models.cue import (
12+
AlertConfig,
13+
Cue,
14+
CueList,
15+
DeliveryConfig,
16+
VerificationConfig,
17+
)
18+
19+
20+
def _base_cue_payload() -> dict:
21+
return {
22+
"id": "cue_test123",
23+
"name": "test-cue",
24+
"status": "active",
25+
"transport": "webhook",
26+
"schedule": {"type": "recurring", "cron": "0 9 * * *", "timezone": "UTC"},
27+
"callback": {"url": "https://example.com/webhook", "method": "POST"},
28+
"payload": {},
29+
"retry": {"max_attempts": 3, "backoff_minutes": [1, 5, 15]},
30+
"next_run": None,
31+
"last_run": None,
32+
"run_count": 0,
33+
"fired_count": 0,
34+
"warning": None,
35+
"created_at": "2026-05-04T17:00:00Z",
36+
"updated_at": "2026-05-04T17:00:00Z",
37+
}
38+
39+
40+
class TestNewFields:
41+
def test_old_response_still_parses(self):
42+
# Older server responses without the new fields must still
43+
# deserialize cleanly. Pinning so a future required-field
44+
# addition doesn't break SDK callers reading legacy data.
45+
cue = Cue.model_validate(_base_cue_payload())
46+
assert cue.delivery is None
47+
assert cue.alerts is None
48+
assert cue.catch_up is None
49+
assert cue.verification is None
50+
assert cue.on_success_fire is None
51+
assert cue.require_payload_override is False
52+
assert cue.required_payload_keys is None
53+
assert cue.stats is None
54+
55+
def test_delivery_config_parses(self):
56+
payload = _base_cue_payload()
57+
payload["delivery"] = {"timeout_seconds": 60, "outcome_deadline_seconds": 600}
58+
cue = Cue.model_validate(payload)
59+
assert isinstance(cue.delivery, DeliveryConfig)
60+
assert cue.delivery.timeout_seconds == 60
61+
assert cue.delivery.outcome_deadline_seconds == 600
62+
63+
def test_alerts_config_forward_compat(self):
64+
# AlertConfig has extra="allow" so server can grow the object
65+
# without the SDK breaking. Pin the forward-compat behavior.
66+
payload = _base_cue_payload()
67+
payload["alerts"] = {
68+
"channels": ["email", "slack"],
69+
"future_field_we_dont_know_about_yet": "value",
70+
}
71+
cue = Cue.model_validate(payload)
72+
assert isinstance(cue.alerts, AlertConfig)
73+
assert cue.alerts.model_extra["channels"] == ["email", "slack"]
74+
assert cue.alerts.model_extra["future_field_we_dont_know_about_yet"] == "value"
75+
76+
def test_catch_up_passthrough(self):
77+
for v in ("run_once_if_missed", "skip_missed", "replay_all"):
78+
payload = _base_cue_payload()
79+
payload["catch_up"] = v
80+
cue = Cue.model_validate(payload)
81+
assert cue.catch_up == v
82+
83+
def test_verification_config_with_assertions(self):
84+
payload = _base_cue_payload()
85+
payload["verification"] = {
86+
"mode": "evidence_required",
87+
"required_assertions": ["external_id", "result_url"],
88+
}
89+
cue = Cue.model_validate(payload)
90+
assert isinstance(cue.verification, VerificationConfig)
91+
assert cue.verification.mode == "evidence_required"
92+
assert cue.verification.required_assertions == ["external_id", "result_url"]
93+
94+
def test_verification_config_forward_compat(self):
95+
payload = _base_cue_payload()
96+
payload["verification"] = {
97+
"mode": "manual",
98+
"future_assertion_subkey": {"nested": True},
99+
}
100+
cue = Cue.model_validate(payload)
101+
assert cue.verification.mode == "manual"
102+
assert cue.verification.model_extra["future_assertion_subkey"] == {"nested": True}
103+
104+
def test_on_success_fire(self):
105+
payload = _base_cue_payload()
106+
payload["on_success_fire"] = "cue_chained123"
107+
cue = Cue.model_validate(payload)
108+
assert cue.on_success_fire == "cue_chained123"
109+
110+
def test_require_payload_override_explicitly_true(self):
111+
payload = _base_cue_payload()
112+
payload["require_payload_override"] = True
113+
payload["required_payload_keys"] = ["task", "message"]
114+
cue = Cue.model_validate(payload)
115+
assert cue.require_payload_override is True
116+
assert cue.required_payload_keys == ["task", "message"]
117+
118+
def test_stats_blob(self):
119+
# CueDetailResponse-only field. Pin that the SDK accepts the
120+
# blob shape the server returns, opaquely (the keys evolve
121+
# server-side and we don't want to lock them).
122+
payload = _base_cue_payload()
123+
payload["stats"] = {
124+
"success_rate_7d": 0.94,
125+
"miss_rate_7d": 0.02,
126+
"total_executions_7d": 156,
127+
}
128+
cue = Cue.model_validate(payload)
129+
assert cue.stats == {
130+
"success_rate_7d": 0.94,
131+
"miss_rate_7d": 0.02,
132+
"total_executions_7d": 156,
133+
}
134+
135+
136+
class TestRoundTrip:
137+
def test_full_response_roundtrip(self):
138+
# Comprehensive: every new field set, ensure the model accepts
139+
# the union shape and re-serializes to a dict that contains all
140+
# the field names the server expects to see in a write-side
141+
# request (when the SDK eventually grows builder-style helpers
142+
# that send these fields back to the server).
143+
payload = _base_cue_payload()
144+
payload.update({
145+
"delivery": {"timeout_seconds": 90, "outcome_deadline_seconds": 900},
146+
"alerts": {"channels": ["email"]},
147+
"catch_up": "skip_missed",
148+
"verification": {
149+
"mode": "evidence_required",
150+
"required_assertions": ["external_id"],
151+
},
152+
"on_success_fire": "cue_next",
153+
"require_payload_override": True,
154+
"required_payload_keys": ["task"],
155+
"stats": {"success_rate_7d": 1.0},
156+
})
157+
cue = Cue.model_validate(payload)
158+
159+
# All fields present in dict roundtrip.
160+
d = cue.model_dump()
161+
assert d["delivery"]["timeout_seconds"] == 90
162+
assert d["catch_up"] == "skip_missed"
163+
assert d["on_success_fire"] == "cue_next"
164+
assert d["require_payload_override"] is True
165+
assert d["required_payload_keys"] == ["task"]
166+
167+
168+
class TestCueList:
169+
def test_list_with_new_fields_in_each_cue(self):
170+
list_payload = {
171+
"cues": [
172+
{**_base_cue_payload(), "id": "cue_1", "require_payload_override": True},
173+
{**_base_cue_payload(), "id": "cue_2", "catch_up": "replay_all"},
174+
],
175+
"total": 2,
176+
"limit": 50,
177+
"offset": 0,
178+
}
179+
cl = CueList.model_validate(list_payload)
180+
assert len(cl.cues) == 2
181+
assert cl.cues[0].require_payload_override is True
182+
assert cl.cues[1].catch_up == "replay_all"

0 commit comments

Comments
 (0)