Skip to content

Commit 6364d05

Browse files
authored
Merge branch 'main' into claude/python-sdk-audit-o2iid9-13-null-data-validation
2 parents d3c6d0f + 840b499 commit 6364d05

63 files changed

Lines changed: 3618 additions & 3648 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,31 @@ and its ``Retry`` class is re-exported from ``seam`` for convenience:
682682
retries=Retry(total=3, backoff_factor=0.5, status_forcelist=[503]),
683683
)
684684
685+
Bringing your own transport
686+
+++++++++++++++++++++++++++
687+
688+
A custom ``transport`` or ``mounts`` passed through ``httpx_options`` replaces
689+
the transport the SDK builds, so it takes full responsibility for retries:
690+
requests through it are not retried unless you wrap it yourself. Combining
691+
either with the ``retries`` option raises a ``SeamInvalidOptionsError``. To
692+
retry through your own transport, wrap it with ``RetryTransport``:
693+
694+
.. code-block:: python
695+
696+
from httpx_retries import RetryTransport
697+
698+
from seam import Seam, Retry
699+
700+
seam = Seam(
701+
api_key="your-api-key",
702+
httpx_options={
703+
"transport": RetryTransport(
704+
transport=MyCustomTransport(),
705+
retry=Retry(total=2, status_forcelist=[429, 503]),
706+
),
707+
},
708+
)
709+
685710
Configuring the httpx client
686711
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
687712

codegen/layouts/partials/method-docstring.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
:param wait_for_action_attempt: Whether, and for how long, to wait for the action attempt to finish.{{/if}}{{#unless (eq returnType "None")}}
66

7-
:returns: {{{indent (pythonDoc responseDescription) 8}}}{{/unless}}{{#if hasRequiredParameters}}
7+
:returns: {{{indent (pythonDoc responseDescription) 8}}}{{/unless}}{{#if requiresAtLeastOneParameter}}
88

99
:raises ValueError: At least one parameter must be provided.{{/if}}{{#if isDeprecated}}
1010

codegen/layouts/partials/route-method.hbs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
@route_metadata(path="{{path}}", has_required_parameters={{#if hasRequiredParameters}}True{{else}}False{{/if}}, has_pagination={{#if hasPagination}}True{{else}}False{{/if}})
1+
@route_metadata(path="{{path}}", at_least_one_parameter_names=({{#each atLeastOneParameterNames}}"{{this}}",{{#unless @last}} {{/unless}}{{/each}}), has_pagination={{#if hasPagination}}True{{else}}False{{/if}})
22
{{#if isAsync}}async {{/if}}def {{> method-signature}}:
33
"""{{> method-docstring}}"""
44
{{payloadVar}}: Dict[str, Any] = {}
@@ -7,9 +7,12 @@
77
if {{name}} is not None:
88
{{../payloadVar}}["{{name}}"] = {{name}}
99
{{/each}}
10-
{{#if hasRequiredParameters}}
10+
{{#if requiresAtLeastOneParameter}}
1111

12-
if not {{payloadVar}}:
12+
if all(
13+
param is None
14+
for param in ({{#each atLeastOneParameterNames}}{{this}},{{#unless @last}} {{/unless}}{{/each}})
15+
):
1316
raise ValueError("At least one parameter is required for {{path}}")
1417
{{/if}}
1518

@@ -24,16 +27,19 @@
2427

2528
return {{#if isAsync}}await resolve_action_attempt_async{{else}}resolve_action_attempt{{/if}}(
2629
client=self.client,
27-
action_attempt=action_attempt_from_dict(res["action_attempt"]),
30+
action_attempt=action_attempt_from_dict(unwrap(res, "action_attempt", "{{path}}")),
2831
wait_for_action_attempt=wait_for_action_attempt
2932
)
3033
{{else if (eq returnType "None")}}
3134

3235
return None
3336
{{else if (isListType returnType)}}
3437

35-
return [{{fromDict (listItemType returnType)}}(item) for item in res{{#each returnPath}}["{{this}}"]{{/each}}]
38+
return {{#if hasPagination}}PaginatedList(
39+
[{{fromDict (listItemType returnType)}}(item) for item in unwrap_list(res, "{{returnPath.[0]}}", "{{path}}")],
40+
pagination=res.get("pagination"),
41+
){{else}}[{{fromDict (listItemType returnType)}}(item) for item in unwrap_list(res, "{{returnPath.[0]}}", "{{path}}")]{{/if}}
3642
{{else}}
3743

38-
return {{fromDict returnType}}(res{{#each returnPath}}["{{this}}"]{{/each}})
44+
return {{fromDict returnType}}(unwrap(res, "{{returnPath.[0]}}", "{{path}}"))
3945
{{/if}}

codegen/layouts/route.hbs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ from .{{module}} import {{abstractClassName}}, {{className}}, {{asyncAbstractCla
1414
{{#if importResolveActionAttempt}}
1515
from ..modules.action_attempts import resolve_action_attempt, resolve_action_attempt_async
1616
{{/if}}
17+
{{#if importUnwrap}}
18+
from ..response import unwrap
19+
{{/if}}
20+
{{#if importUnwrapList}}
21+
from ..response import unwrap_list
22+
{{/if}}
23+
{{#if importPaginatedList}}
24+
from ..pagination import PaginatedList
25+
{{/if}}
1726

1827

1928
{{> abstract-route-class abstractClass}}

codegen/lib/layouts/route.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export interface MethodLayoutContext {
1414
httpVerb: string
1515
payloadVar: string
1616
payloadArg: string
17-
hasRequiredParameters: boolean
17+
requiresAtLeastOneParameter: boolean
18+
atLeastOneParameterNames: string[]
1819
hasPagination: boolean
1920
description: string
2021
responseDescription: string
@@ -61,9 +62,22 @@ export interface RouteLayoutContext {
6162
}>
6263
importResolveActionAttempt: boolean
6364
importNull: boolean
65+
importUnwrap: boolean
66+
importUnwrapList: boolean
67+
importPaginatedList: boolean
6468
methods: MethodLayoutContext[]
6569
}
6670

71+
const paginationParameterNames = new Set(['limit', 'page_cursor'])
72+
73+
const getAtLeastOneParameterNames = (method: ClassMethod): string[] =>
74+
method.hasRequiredParameters &&
75+
method.parameters.every(({ required }) => !(required ?? false))
76+
? method.parameters
77+
.map(({ name }) => name)
78+
.filter((name) => !paginationParameterNames.has(name))
79+
: []
80+
6781
const getRequestLayoutContext = (
6882
preferredMethod: string,
6983
): Pick<MethodLayoutContext, 'httpVerb' | 'payloadVar' | 'payloadArg'> => {
@@ -82,7 +96,8 @@ export const getMethodLayoutContext = (
8296
name: method.methodName,
8397
path: method.path,
8498
...getRequestLayoutContext(method.preferredMethod),
85-
hasRequiredParameters: method.hasRequiredParameters,
99+
requiresAtLeastOneParameter: getAtLeastOneParameterNames(method).length > 0,
100+
atLeastOneParameterNames: getAtLeastOneParameterNames(method),
86101
hasPagination: method.hasPagination,
87102
description: method.description,
88103
responseDescription: method.responseDescription,
@@ -130,6 +145,18 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
130145
params.some(({ isNullable }) => isNullable),
131146
)
132147

148+
const importUnwrap = methods.some(
149+
({ returnPath, returnType }) =>
150+
returnPath.length > 0 && !returnType.startsWith('List['),
151+
)
152+
153+
const importUnwrapList = methods.some(
154+
({ returnPath, returnType }) =>
155+
returnPath.length > 0 && returnType.startsWith('List['),
156+
)
157+
158+
const importPaginatedList = methods.some(({ hasPagination }) => hasPagination)
159+
133160
const showPass =
134161
cls.methods.length === 0 && cls.childClassIdentifiers.length === 0
135162

@@ -172,6 +199,9 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
172199
})),
173200
importResolveActionAttempt,
174201
importNull,
202+
importUnwrap,
203+
importUnwrapList,
204+
importPaginatedList,
175205
methods,
176206
}
177207
}

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"packageManager": "npm@11.19.0",
3131
"devDependencies": {
3232
"@seamapi/blueprint": "^1.10.0",
33-
"@seamapi/fake-seam-connect": "2.0.5",
33+
"@seamapi/fake-seam-connect": "2.0.6",
3434
"@seamapi/smith": "^1.1.0",
3535
"@seamapi/types": "1.1047.0",
3636
"change-case": "^5.4.4",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "seam"
3-
version = "3.13.3"
3+
version = "3.13.8"
44
description = "SDK for the Seam API written in Python."
55
authors = [{ name = "Seam Labs, Inc.", email = "engineering@getseam.com" }]
66
license = "MIT"

seam/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .exceptions import (
99
SeamError,
1010
SeamHttpApiError,
11+
SeamHttpInvalidResponseError,
1112
SeamHttpUnauthorizedError,
1213
SeamHttpInvalidInputError,
1314
SeamValidationError,

seam/client.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import Mapping
2+
from json import JSONDecodeError
23
from typing import Any, Dict, Optional
34
from importlib.metadata import version
45
import abc
@@ -14,6 +15,7 @@
1415
SeamHttpUnauthorizedError,
1516
)
1617
from .null import replace_null
18+
from .options import SeamInvalidOptionsError
1719
from .strict_url_search_params_serializer import serialize_url_search_params
1820

1921
SDK_HEADERS = {
@@ -68,7 +70,13 @@ def _handle_response(self, response: Response):
6870
self._handle_error_response(response)
6971

7072
if "application/json" in response.headers.get("content-type", ""):
71-
return response.json()
73+
try:
74+
return response.json()
75+
except JSONDecodeError:
76+
# A body that lies about its content type is handed on as
77+
# text, so readers report an invalid response instead of
78+
# leaking a decode error.
79+
return response.text
7280

7381
return response.text
7482

@@ -105,14 +113,24 @@ def __init__(
105113
self,
106114
base_url: str,
107115
auth_headers: Dict[str, str],
108-
retries: Optional[Retry] = DEFAULT_RETRIES,
116+
retries: Optional[Retry] = None,
109117
timeout: Optional[float] = DEFAULT_TIMEOUT,
110118
httpx_options: Optional[Dict[str, Any]] = None,
111119
**kwargs,
112120
):
113121
options = _build_client_options(base_url, timeout, httpx_options, kwargs)
114122

115123
custom_headers = options.pop("headers", {})
124+
125+
if retries is not None and (
126+
options.get("transport") is not None or options.get("mounts") is not None
127+
):
128+
raise SeamInvalidOptionsError(
129+
"The retries option cannot be combined with a custom transport "
130+
"or mounts, which bypass the retry transport; wrap your "
131+
"transport with httpx_retries.RetryTransport instead"
132+
)
133+
116134
self._retry_policy = DEFAULT_RETRIES if retries is None else retries
117135

118136
super().__init__(**options)
@@ -173,14 +191,24 @@ def __init__(
173191
self,
174192
base_url: str,
175193
auth_headers: Dict[str, str],
176-
retries: Optional[Retry] = DEFAULT_RETRIES,
194+
retries: Optional[Retry] = None,
177195
timeout: Optional[float] = DEFAULT_TIMEOUT,
178196
httpx_options: Optional[Dict[str, Any]] = None,
179197
**kwargs,
180198
):
181199
options = _build_client_options(base_url, timeout, httpx_options, kwargs)
182200

183201
custom_headers = options.pop("headers", {})
202+
203+
if retries is not None and (
204+
options.get("transport") is not None or options.get("mounts") is not None
205+
):
206+
raise SeamInvalidOptionsError(
207+
"The retries option cannot be combined with a custom transport "
208+
"or mounts, which bypass the retry transport; wrap your "
209+
"transport with httpx_retries.RetryTransport instead"
210+
)
211+
184212
self._retry_policy = DEFAULT_RETRIES if retries is None else retries
185213

186214
super().__init__(**options)

0 commit comments

Comments
 (0)