Skip to content

Commit ad7bd44

Browse files
committed
feat: make response parsing total and surface unknown action attempt statuses
Seam adds event types, action types, and error codes between SDK releases. A payload this version does not recognize should stay readable rather than cost the caller the whole response. Reading a response no longer fails on the shape of the payload. Shared helpers in seam/parse.py guard every conversion the generated dataclasses perform, so a list sent as a scalar reads as empty, a nested object sent as a scalar reads as None, an unrecognized discriminator yields a DeepAttrDict, and a known variant whose payload does not convert degrades to one too. A malformed free-form record now degrades on its own rather than costing the surrounding resource its generated type. Waiting on an action attempt whose status is neither pending, success, nor error raises the new SeamActionAttemptUnknownStatusError instead of returning it as a success. Reporting success would tell the caller the action completed when the SDK cannot tell, and the previous behavior did exactly that. The error subclasses SeamActionAttemptError, so existing handlers for that base keep working. Reading the status no longer assumes the attempt carries one, which used to raise AttributeError on a fallback payload. DeepAttrDict is re-exported from the package root so callers can test for the fallback against the public API. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2kJ4nGaM8imZCVMEKjmXA
1 parent bd548fb commit ad7bd44

40 files changed

Lines changed: 3119 additions & 1489 deletions

‎codegen/layouts/partials/resource-dataclass.hbs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@
3131
{{#unless properties}}
3232
{{memberIndent}} # pylint: disable=unused-argument
3333
{{/unless}}
34+
{{memberIndent}} if not isinstance(d, dict):
35+
{{memberIndent}} d = {}
3436
{{memberIndent}} return cls(
3537
{{#each properties}}
36-
{{../memberIndent}} {{pythonIdentifier name}}={{#if isRequiredObject}}cls.{{nestedClassName}}.from_dict(d.get("{{name}}") or {}){{else}}{{#if isObject}}cls.{{nestedClassName}}.from_dict(d.get("{{name}}")) if d.get("{{name}}") is not None else None{{else}}{{#if isDiscriminatedObjectList}}[_from_discriminated_dict(i, cls._{{nestedClassName}}Variants, "{{discriminator}}") for i in d.get("{{name}}") or []]{{else}}{{#if isObjectList}}[cls.{{nestedClassName}}.from_dict(i) for i in d.get("{{name}}") or []]{{else}}{{#if isDictParam}}DeepAttrDict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}}{{/if}}{{/if}}{{/if}}{{/if}},
38+
{{../memberIndent}} {{pythonIdentifier name}}={{#if isRequiredObject}}_required_object_from_dict(cls.{{nestedClassName}}, d.get("{{name}}")){{else}}{{#if isObject}}_object_from_dict(cls.{{nestedClassName}}, d.get("{{name}}")){{else}}{{#if isDiscriminatedObjectList}}_discriminated_list_from_dict(d.get("{{name}}"), cls._{{nestedClassName}}Variants, "{{discriminator}}"){{else}}{{#if isObjectList}}_object_list_from_dict(cls.{{nestedClassName}}, d.get("{{name}}")){{else}}{{#if isDictParam}}_record_from_dict({{/if}}d.get("{{name}}", None){{#if isDictParam}}){{/if}}{{/if}}{{/if}}{{/if}}{{/if}},
3739
{{/each}}
3840
{{memberIndent}} )

‎codegen/layouts/resource.hbs‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
from typing import Any, Dict, List, Literal, Optional, {{#if union.secondaryDiscriminator}}Tuple, {{/if}}Union{{#if union}}, cast{{/if}}
22
from dataclasses import dataclass
33
from ..deep_attr_dict import DeepAttrDict
4+
from ..parse import (
5+
discriminated_list_from_dict as _discriminated_list_from_dict,
6+
object_from_dict as _object_from_dict,
7+
object_list_from_dict as _object_list_from_dict,
8+
record_from_dict as _record_from_dict,
9+
required_object_from_dict as _required_object_from_dict,
10+
)
411
from ..resource_mapping import ResourceMapping
512

6-
{{#if hasDiscriminatedLists}}
7-
8-
def _from_discriminated_dict(
9-
d: Any, variants: Dict[str, Any], discriminator: str
10-
) -> Any:
11-
variant = variants.get(d.get(discriminator))
12-
return DeepAttrDict(d) if variant is None else variant.from_dict(d)
13-
{{/if}}
14-
1513
{{#each classes}}
1614
{{> resource-dataclass}}
1715

@@ -36,11 +34,28 @@ def _from_discriminated_dict(
3634
def {{union.fromDictName}}(d: Any) -> {{union.className}}:
3735
"""Deserialize a known {{union.discriminator}}{{#if union.secondaryDiscriminator}} and {{union.secondaryDiscriminator}}{{/if}} variant.
3836

39-
Unknown discriminator values return ``DeepAttrDict`` so payloads from a
40-
newer API remain readable. The static return type covers known variants.
37+
An unrecognized discriminator, or a known one whose payload does not
38+
convert, returns ``DeepAttrDict`` so payloads from a newer API remain
39+
readable. The static return type covers known variants.
4140
"""
42-
variant = {{union.variantsName}}.get({{#if union.secondaryDiscriminator}}(d.get("{{union.discriminator}}"), d.get("{{union.secondaryDiscriminator}}")){{else}}d.get("{{union.discriminator}}"){{/if}})
41+
if not isinstance(d, dict):
42+
return cast({{union.className}}, DeepAttrDict(d) if isinstance(d, dict) else d)
43+
{{#if union.secondaryDiscriminator}}
44+
key = (d.get("{{union.discriminator}}"), d.get("{{union.secondaryDiscriminator}}"))
45+
variant = (
46+
{{union.variantsName}}.get(cast(Tuple[str, str], key))
47+
if isinstance(key[0], str) and isinstance(key[1], str)
48+
else None
49+
)
50+
{{else}}
51+
key = d.get("{{union.discriminator}}")
52+
variant = {{union.variantsName}}.get(key) if isinstance(key, str) else None
53+
{{/if}}
4354
if variant is None:
4455
return cast({{union.className}}, DeepAttrDict(d))
45-
return variant.from_dict(d)
56+
try:
57+
return variant.from_dict(d)
58+
except Exception: # pylint: disable=broad-exception-caught
59+
# A known discriminator whose payload does not convert is still readable.
60+
return cast({{union.className}}, DeepAttrDict(d))
4661
{{/if}}

‎seam/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
SeamActionAttemptError,
1717
SeamActionAttemptFailedError,
1818
SeamActionAttemptTimeoutError,
19+
SeamActionAttemptUnknownStatusError,
1920
)
21+
from .deep_attr_dict import DeepAttrDict
2022
from .seam_webhook import SeamWebhook
2123
from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError
2224
from .null import NULL, Null

‎seam/exceptions.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,36 @@ def __init__(self, action_attempt: PendingActionAttempt, timeout: float):
254254
message = f"Timed out waiting for action attempt after {timeout}s"
255255
super().__init__(message, action_attempt)
256256
self.name = self.__class__.__name__
257+
258+
259+
class SeamActionAttemptUnknownStatusError(SeamActionAttemptError):
260+
"""
261+
Exception raised when an action attempt reports a status this SDK does not know.
262+
263+
Seam adds action attempt statuses over time. Waiting for an attempt promises to
264+
return a succeeded attempt or raise, and an unrecognized status supports neither
265+
conclusion: reporting success would claim the action completed when the SDK
266+
cannot tell, and polling on would block until the timeout and then report a
267+
misleading timeout. Read ``action_attempt`` to inspect the status directly.
268+
269+
:ivar name: Name of the exception class
270+
:vartype name: str
271+
:ivar status: The unrecognized status reported by the API
272+
:vartype status: str
273+
"""
274+
275+
def __init__(self, action_attempt: ActionAttempt, status: str):
276+
"""
277+
:param action_attempt: The ActionAttempt object carrying the unknown status
278+
:type action_attempt: ActionAttempt
279+
:param status: The unrecognized status reported by the API
280+
:type status: str
281+
"""
282+
283+
message = (
284+
f'Action attempt reported an unknown status "{status}". '
285+
"This SDK version may predate it; upgrade or read the action attempt directly."
286+
)
287+
super().__init__(message, action_attempt)
288+
self.name = self.__class__.__name__
289+
self.status = status

‎seam/modules/action_attempts.py‎

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
from typing import Dict, Optional, Union
1+
from typing import Dict, Optional, Union, cast
22
import asyncio
33
import time
44

55
from ..client import AsyncSeamHttpClient, SeamHttpClient
6-
from ..exceptions import SeamActionAttemptFailedError, SeamActionAttemptTimeoutError
6+
from ..exceptions import (
7+
SeamActionAttemptFailedError,
8+
SeamActionAttemptTimeoutError,
9+
SeamActionAttemptUnknownStatusError,
10+
)
711
from ..options import SeamInvalidOptionsError
8-
from ..resources import ActionAttempt, SuccessActionAttempt, action_attempt_from_dict
12+
from ..resources import (
13+
ActionAttempt,
14+
ErrorActionAttempt,
15+
PendingActionAttempt,
16+
SuccessActionAttempt,
17+
action_attempt_from_dict,
18+
)
919
from ..response import unwrap
1020

1121
TIMEOUT = 5.0
@@ -14,6 +24,16 @@
1424
WAIT_FOR_ACTION_ATTEMPT_OPTION_KEYS = ("timeout", "polling_interval")
1525

1626

27+
def _status_of(action_attempt: ActionAttempt) -> Optional[str]:
28+
"""Read the status without assuming the attempt carries one.
29+
30+
An attempt from a newer API deserializes to a DeepAttrDict, where a missing
31+
key raises rather than reading as None.
32+
"""
33+
34+
return getattr(action_attempt, "status", None)
35+
36+
1737
def validate_wait_for_action_attempt(
1838
value: Optional[Union[bool, Dict[str, float]]],
1939
) -> None:
@@ -93,20 +113,28 @@ def poll_until_ready(
93113
if action_attempt is None:
94114
action_attempt = get_action_attempt(client, action_attempt_id)
95115

96-
while action_attempt.status == "pending":
116+
while _status_of(action_attempt) == "pending":
97117
remaining = deadline - time.monotonic()
98118

99119
if remaining <= 0:
100-
raise SeamActionAttemptTimeoutError(action_attempt, timeout)
120+
raise SeamActionAttemptTimeoutError(
121+
cast(PendingActionAttempt, action_attempt), timeout
122+
)
101123

102124
time.sleep(min(polling_interval, remaining))
103125

104126
action_attempt = get_action_attempt(client, action_attempt_id)
105127

106-
if action_attempt.status == "error":
107-
raise SeamActionAttemptFailedError(action_attempt)
128+
if _status_of(action_attempt) == "error":
129+
raise SeamActionAttemptFailedError(cast(ErrorActionAttempt, action_attempt))
108130

109-
return action_attempt
131+
# Neither pending, success, nor error: a status added after this SDK release.
132+
# Waiting cannot conclude, so say so rather than pass it off as a success.
133+
status = _status_of(action_attempt)
134+
if status != "success":
135+
raise SeamActionAttemptUnknownStatusError(action_attempt, str(status))
136+
137+
return cast(SuccessActionAttempt, action_attempt)
110138

111139

112140
def resolve_action_attempt(
@@ -165,20 +193,28 @@ async def poll_until_ready_async(
165193
if action_attempt is None:
166194
action_attempt = await get_action_attempt_async(client, action_attempt_id)
167195

168-
while action_attempt.status == "pending":
196+
while _status_of(action_attempt) == "pending":
169197
remaining = deadline - time.monotonic()
170198

171199
if remaining <= 0:
172-
raise SeamActionAttemptTimeoutError(action_attempt, timeout)
200+
raise SeamActionAttemptTimeoutError(
201+
cast(PendingActionAttempt, action_attempt), timeout
202+
)
173203

174204
await asyncio.sleep(min(polling_interval, remaining))
175205

176206
action_attempt = await get_action_attempt_async(client, action_attempt_id)
177207

178-
if action_attempt.status == "error":
179-
raise SeamActionAttemptFailedError(action_attempt)
208+
if _status_of(action_attempt) == "error":
209+
raise SeamActionAttemptFailedError(cast(ErrorActionAttempt, action_attempt))
180210

181-
return action_attempt
211+
# Neither pending, success, nor error: a status added after this SDK release.
212+
# Waiting cannot conclude, so say so rather than pass it off as a success.
213+
status = _status_of(action_attempt)
214+
if status != "success":
215+
raise SeamActionAttemptUnknownStatusError(action_attempt, str(status))
216+
217+
return cast(SuccessActionAttempt, action_attempt)
182218

183219

184220
async def resolve_action_attempt_async(

‎seam/parse.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Total conversion helpers shared by the generated resource dataclasses.
2+
3+
The Seam API grows new event types, action types, and error codes between SDK
4+
releases, so reading a response must never fail on the shape of the payload.
5+
Every helper here degrades instead of raising: an unusable value becomes
6+
``None``, an empty list, or a :class:`DeepAttrDict` carrying the payload
7+
verbatim, so one unexpected field cannot cost the caller the whole response.
8+
9+
The narrow cost is that a genuinely malformed payload is no longer loud. The
10+
payload survives on the returned object, so it stays diagnosable.
11+
"""
12+
13+
from typing import Any, Dict, List
14+
15+
from .deep_attr_dict import DeepAttrDict
16+
17+
18+
def record_from_dict(value: Any) -> Any:
19+
"""Wrap a free-form record for attribute access, passing anything else through.
20+
21+
A record the API sends as something other than an object degrades to the raw
22+
value rather than costing the caller the whole surrounding resource.
23+
"""
24+
25+
return DeepAttrDict(value) if isinstance(value, dict) else value
26+
27+
28+
def object_from_dict(cls: Any, value: Any) -> Any:
29+
"""Convert an optional nested object, or None when it is unusable."""
30+
31+
if not isinstance(value, dict):
32+
return None
33+
34+
try:
35+
return cls.from_dict(value)
36+
except Exception: # pylint: disable=broad-exception-caught
37+
return DeepAttrDict(value)
38+
39+
40+
def required_object_from_dict(cls: Any, value: Any) -> Any:
41+
"""Convert a nested object the caller may always read, never returning None."""
42+
43+
source = value if isinstance(value, dict) else {}
44+
45+
try:
46+
return cls.from_dict(source)
47+
except Exception: # pylint: disable=broad-exception-caught
48+
return DeepAttrDict(source)
49+
50+
51+
def object_list_from_dict(cls: Any, value: Any) -> List[Any]:
52+
"""Convert a list of nested objects, skipping nothing and raising for nothing.
53+
54+
A value that is not a list reads as empty, so callers can always iterate.
55+
"""
56+
57+
if not isinstance(value, list):
58+
return []
59+
60+
return [
61+
object_from_dict(cls, item) if isinstance(item, dict) else item
62+
for item in value
63+
]
64+
65+
66+
def discriminated_from_dict(
67+
value: Any, variants: Dict[str, Any], discriminator: str
68+
) -> Any:
69+
"""Convert one member of a discriminated union.
70+
71+
An unrecognized discriminator, or a known one whose payload does not
72+
convert, yields a DeepAttrDict so a newer API stays readable.
73+
"""
74+
75+
if not isinstance(value, dict):
76+
# Not an object at all; hand it back untouched rather than lose it.
77+
return value
78+
79+
key = value.get(discriminator)
80+
variant = variants.get(key) if isinstance(key, str) else None
81+
82+
if variant is None:
83+
return DeepAttrDict(value)
84+
85+
try:
86+
return variant.from_dict(value)
87+
except Exception: # pylint: disable=broad-exception-caught
88+
return DeepAttrDict(value)
89+
90+
91+
def discriminated_list_from_dict(
92+
value: Any, variants: Dict[str, Any], discriminator: str
93+
) -> List[Any]:
94+
"""Convert a list of discriminated union members, degrading item by item."""
95+
96+
if not isinstance(value, list):
97+
return []
98+
99+
return [discriminated_from_dict(item, variants, discriminator) for item in value]

0 commit comments

Comments
 (0)