Skip to content

Commit 52cfee7

Browse files
razor-xclaude
andauthored
feat: apply the URL search params standard and type nullable params (#614)
* feat: serialize request params with the URL search params standard The Seam API parses URL search params as complex types, so the SDK has to build the query string itself. Serialize any mapping passed as params and set the result on the url, rather than letting httpx encode the params with its own rules, which represent arrays and nested objects differently. Replace the NULL sentinel with null in request bodies as well, so a param set to NULL is sent as null on either transport, and document how NULL tells an explicitly null param apart from an omitted one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A * Apply suggestions from code review Co-authored-by: Evan Sosenko <razorx@evansosenko.com> * feat: type nullable params with the explicit null sentinel Consume the blueprint isNullable flag so a param the Seam API documents as nullable is typed to accept NULL, and a param that is merely optional is not. Optional params are omitted by passing None, where sending null would unset a value instead, so accepting the sentinel everywhere would invite exactly the mistake it exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A * test: name the search param tests for what they do Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84b5485 commit 52cfee7

34 files changed

Lines changed: 518 additions & 114 deletions

README.rst

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Contents
4747

4848
* `Action Attempts`_
4949

50+
* `Setting a Param to Null`_
51+
5052
* `Pagination`_
5153

5254
* `Manually fetch pages with the next_page_cursor`_
@@ -280,6 +282,56 @@ For example:
280282
except SeamActionAttemptTimeoutError as e:
281283
print("Door took too long to unlock")
282284
285+
Setting a Param to Null
286+
~~~~~~~~~~~~~~~~~~~~~~~
287+
288+
The Seam API tells an omitted param apart from one explicitly set to null.
289+
In an update request, an omitted param leaves the current value unchanged,
290+
while a null param unsets it.
291+
292+
Python has a single nil value `None` which represents an undefined parameter. This SDK provides an explicit null value to send in requests.
293+
A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as `null`:
294+
295+
.. code-block:: python
296+
297+
from seam import NULL, Seam
298+
299+
seam = Seam()
300+
301+
# Leaves the name unchanged.
302+
seam.devices.update(device_id="your-device-id", name=None)
303+
304+
# Unsets the name.
305+
seam.devices.update(device_id="your-device-id", name=NULL)
306+
307+
Because unsetting a value cannot be undone, ``None`` means the safe option of
308+
omitting the param, and sending null is always explicit.
309+
This is why a param is never sent as null by default,
310+
even though ``None`` is the natural way to spell null in Python.
311+
312+
``NULL`` behaves the same way in a request body and in a URL search param.
313+
Its type is exported as ``Null`` for annotating your own code:
314+
315+
.. code-block:: python
316+
317+
from typing import Optional, Union
318+
319+
from seam import NULL, Null
320+
321+
name: Optional[Union[str, Null]] = NULL
322+
323+
Only params the Seam API documents as nullable accept ``NULL``.
324+
The generated method signatures say which ones those are,
325+
so a type checker rejects ``NULL`` anywhere else:
326+
327+
.. code-block:: python
328+
329+
# name is nullable, so it may be unset.
330+
seam.devices.update(device_id="your-device-id", name=NULL)
331+
332+
# is_managed is not, so this fails the type check.
333+
seam.devices.update(device_id="your-device-id", is_managed=NULL)
334+
283335
Pagination
284336
~~~~~~~~~~
285337

@@ -562,8 +614,9 @@ A client may percent-encode a few characters differently than
562614
``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``,
563615
which the Seam API reads as the same params either way.
564616

565-
A param set to ``None`` is omitted, while a param set to ``seam.NULL``
566-
is serialized to an empty value, which the Seam API reads as null.
617+
A param set to ``None`` is omitted, while a param set to ``NULL``
618+
is serialized to an empty value, which the Seam API reads as null,
619+
as described in `Setting a Param to Null`_.
567620
A param that cannot be represented raises a ``seam.UnserializableParamError``.
568621

569622
The Seam API parses these params with the corresponding `parser`_.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{{name}}(self{{#if params}}, *{{else}}{{#if (eq returnType "ActionAttempt")}}, *{{/if}}{{/if}}{{#each params}}, {{name}}: {{#if required}}{{type}}{{else}}Optional[{{type}}] = None{{/if}}{{/each}}{{#if (eq returnType "ActionAttempt")}}, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None{{/if}}) -> {{returnType}}
1+
{{name}}(self{{#if params}}, *{{else}}{{#if (eq returnType "ActionAttempt")}}, *{{/if}}{{/if}}{{#each params}}, {{name}}: {{#if required}}{{nullableType type isNullable}}{{else}}Optional[{{nullableType type isNullable}}] = None{{/if}}{{/each}}{{#if (eq returnType "ActionAttempt")}}, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None{{/if}}) -> {{returnType}}

codegen/layouts/route.hbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ from typing import Optional, Any, List, Dict, Union
22
import abc
33
from ..client import SeamHttpClient
44
from ..route import route_metadata
5+
{{#if importNull}}
6+
from ..null import Null
7+
{{/if}}
58
{{#if resourceClasses}}
69
from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}})
710
{{/if}}

codegen/lib/class-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
export interface ClassMethodParameter {
55
name: string
66
type: string
7+
isNullable: boolean
78
description: string
89
isDeprecated: boolean
910
deprecationMessage: string

codegen/lib/handlebars-helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ export const indent = (value: string, spaces: number): string =>
5353
export const pythonIdentifier = (name: string): string =>
5454
PYTHON_KEYWORDS.has(name) ? `${name}_` : name
5555

56+
// A param the API documents as nullable may be set to the NULL sentinel, which
57+
// the client serializes to null. Params that are merely optional may not: they
58+
// are omitted by passing None, and sending null would unset a value instead.
59+
export const nullableType = (type: string, isNullable: boolean): string =>
60+
isNullable ? `Union[${type}, Null]` : type
61+
5662
export const isListType = (type: string): boolean => type.startsWith('List[')
5763

5864
export const listItemType = (type: string): string => type.slice(5, -1)

codegen/lib/layouts/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface MethodLayoutContext {
2323
params: Array<{
2424
name: string
2525
type: string
26+
isNullable: boolean
2627
description: string
2728
isDeprecated: boolean
2829
deprecationMessage: string
@@ -53,6 +54,7 @@ export interface RouteLayoutContext {
5354
module: string
5455
}>
5556
importResolveActionAttempt: boolean
57+
importNull: boolean
5658
methods: MethodLayoutContext[]
5759
}
5860

@@ -83,6 +85,7 @@ export const getMethodLayoutContext = (
8385
params: sortClassMethodParameters(method.parameters).map((parameter) => ({
8486
name: parameter.name,
8587
type: parameter.type,
88+
isNullable: parameter.isNullable,
8689
description: parameter.description,
8790
isDeprecated: parameter.isDeprecated,
8891
deprecationMessage: parameter.deprecationMessage,
@@ -108,6 +111,10 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
108111
const abstractClassName = `Abstract${cls.name}`
109112
const methods = cls.methods.map(getMethodLayoutContext)
110113

114+
const importNull = methods.some(({ params }) =>
115+
params.some(({ isNullable }) => isNullable),
116+
)
117+
111118
return {
112119
className: cls.name,
113120
abstractClassName,
@@ -131,6 +138,7 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
131138
module: `${cls.namespace}_${identifier.namespace}`,
132139
})),
133140
importResolveActionAttempt,
141+
importNull,
134142
methods,
135143
}
136144
}

codegen/lib/routes.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export const routes = (
9999
parameters: endpoint.request.parameters.map((parameter) => ({
100100
name: parameter.name,
101101
type: mapParameterToPythonType(parameter),
102+
isNullable: parameter.isNullable,
102103
description: parameter.description,
103104
isDeprecated: parameter.isDeprecated,
104105
deprecationMessage: parameter.deprecationMessage,

seam/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Mapping
12
from typing import Any, Dict, Optional
23
from importlib.metadata import version
34
import abc
@@ -12,6 +13,8 @@
1213
SeamHttpInvalidInputError,
1314
SeamHttpUnauthorizedError,
1415
)
16+
from .null import replace_null
17+
from .url_search_params_serializer import serialize_url_search_params
1518

1619
SDK_HEADERS = {
1720
"seam-sdk-name": "seamapi/python",
@@ -102,6 +105,12 @@ def delete(self, url, json=None, **kwargs) -> Any:
102105
return self.request("DELETE", url, json=json, **kwargs)
103106

104107
def request(self, method, url, *args, **kwargs) -> Any:
108+
if isinstance(kwargs.get("params"), Mapping):
109+
url = with_search_params(url, kwargs.pop("params"))
110+
111+
if "json" in kwargs:
112+
kwargs["json"] = replace_null(kwargs["json"])
113+
105114
response = super().request(method, url, *args, **kwargs)
106115

107116
return self._handle_response(response)
@@ -142,6 +151,15 @@ def _handle_error_response(self, response: Response):
142151
raise SeamHttpApiError(error_details, status_code, request_id)
143152

144153

154+
def with_search_params(url: Any, params: Mapping[str, Any]) -> Any:
155+
query = serialize_url_search_params(params)
156+
157+
if not query:
158+
return url
159+
160+
return httpx.URL(url, query=query.encode())
161+
162+
145163
def is_api_error_response(response: Response) -> bool:
146164
try:
147165
content_type = response.headers.get("content-type", "")

seam/null.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Sending null is explicit and always spelled :data:`NULL`.
1111
"""
1212

13+
from collections.abc import Mapping, Sequence
1314
from typing import Any
1415

1516

@@ -60,3 +61,27 @@ def is_null(value: Any) -> bool:
6061
:returns: Whether the value is the ``NULL`` sentinel"""
6162

6263
return isinstance(value, Null)
64+
65+
66+
def replace_null(value: Any) -> Any:
67+
"""Returns a copy of a value with every :data:`NULL` sentinel replaced by ``None``.
68+
69+
The sentinel only distinguishes an explicit null from an omitted param
70+
within this SDK. Once a request body is being serialized, the param is
71+
known to be present, so the sentinel becomes the null that JSON has.
72+
73+
:param value: The value to copy
74+
:type value: Any
75+
76+
:returns: The value with each ``NULL`` sentinel replaced by ``None``"""
77+
78+
if is_null(value):
79+
return None
80+
81+
if isinstance(value, Mapping):
82+
return {key: replace_null(item) for key, item in value.items()}
83+
84+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
85+
return [replace_null(item) for item in value]
86+
87+
return value

seam/routes/access_codes.py

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

0 commit comments

Comments
 (0)