Skip to content

Commit ca39b44

Browse files
committed
feat: send explicit null request params with seam.NULL
The Seam API distinguishes an omitted param from a param explicitly set to null: in an update request, an omitted param leaves the current value unchanged while a null param unsets it, and some endpoints accept null as a meaningful filter value. Python has a single absence value, so route methods omitted both cases and there was no way to send null. For example, access_grants.list documents null as a filter for Access Grants without an access_grant_key, but passing None dropped the filter and returned every Access Grant. Add the NULL sentinel for a param explicitly set to null. Since sending null is rarely intended and unsetting a value cannot be undone, None keeps meaning the safe option of omitting the param, so this adds the capability without changing the behavior of any existing call. The existing generated route methods need no change: they already omit params set to None, and the client now replaces any remaining NULL sentinel with None so that json serializes it to null. NULL works at any depth, e.g., to clear a single key of an object param. Bind the URL search params serializer to the same convention, replacing its UNDEFINED sentinel: None is JavaScript undefined and is removed, while NULL is JavaScript null and serializes to an empty value. NULL is typed as Any so it may be passed to any param without a type error. Once blueprint exposes isNullable on Parameter, codegen can type nullable params precisely instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A
1 parent 4f0e1b8 commit ca39b44

7 files changed

Lines changed: 284 additions & 49 deletions

File tree

README.rst

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ Contents
6161

6262
* `Webhooks`_
6363

64+
* `Omitted params and null params`_
65+
6466
* `Advanced Usage`_
6567

6668
* `Setting the endpoint`_
@@ -427,6 +429,45 @@ see the `Svix docs for more examples in specific frameworks <https://docs.svix.c
427429
app.run(port=8080)
428430
429431
432+
Omitted params and null params
433+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
434+
435+
The Seam API distinguishes an omitted param from a param explicitly set to null.
436+
In an update request, an omitted param leaves the current value unchanged,
437+
while a null param unsets the current value.
438+
Some endpoints also accept null as a meaningful filter value.
439+
440+
Python has a single absence value, so this SDK maps the two cases as follows:
441+
442+
- ``None``, or simply not passing the param, omits it from the request.
443+
- ``seam.NULL`` sends the param as null.
444+
445+
Sending null is rarely intended and unsetting a value cannot be undone,
446+
so ``None`` means the safe option of omitting the param
447+
and sending null is always explicit:
448+
449+
.. code-block:: python
450+
451+
from seam import NULL, Seam
452+
453+
seam = Seam()
454+
455+
# Unsets the device name.
456+
seam.devices.update(device_id=device_id, name=NULL)
457+
458+
# Leaves the device name unchanged.
459+
seam.devices.update(device_id=device_id, name=None)
460+
461+
# Lists only the Access Grants which have no access_grant_key.
462+
seam.access_grants.list(access_grant_key=NULL)
463+
464+
``NULL`` may be used at any depth, e.g., to clear a single key
465+
while leaving the other keys unchanged:
466+
467+
.. code-block:: python
468+
469+
seam.spaces.update(space_id=space_id, customer_data={"check_in": NULL})
470+
430471
Advanced Usage
431472
~~~~~~~~~~~~~~
432473

@@ -462,8 +503,9 @@ Use it directly when building requests to the Seam API by hand:
462503
463504
Params are sorted by name, so equivalent input always produces the same query string.
464505
Nested dicts are serialized to dot-path keys, e.g., ``{"a": {"b": 1}}`` becomes ``a.b=1``.
465-
Params set to ``None`` are serialized to an empty value, e.g., ``a=``,
466-
while params set to ``seam.UNDEFINED`` are removed.
506+
Params set to ``None`` are omitted,
507+
while params set to ``seam.NULL`` are serialized to an empty value, e.g., ``a=``.
508+
See `Omitted params and null params`_.
467509
A param that cannot be represented raises a ``seam.UnserializableParamError``.
468510

469511
To merge serialized params into existing params, use ``update_url_search_params``:

seam/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
)
1616
from .seam_webhook import SeamWebhook
1717
from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError
18+
from .null import NULL, Null
1819
from .utils.url_search_params_serializer import (
19-
UNDEFINED,
2020
UnserializableParamError,
2121
UrlSearchParams,
2222
serialize_url_search_params,

seam/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
SeamHttpInvalidInputError,
1212
SeamHttpUnauthorizedError,
1313
)
14+
from .null import replace_null
1415

1516
SDK_HEADERS = {
1617
"seam-sdk-name": "seamapi/python",
@@ -59,6 +60,12 @@ def __init__(
5960

6061
def request(self, method, url, *args, **kwargs):
6162
url = urljoin(self.base_url, url)
63+
64+
# Route methods omit params set to None, so any remaining NULL sentinel
65+
# is an explicit null and becomes None for JSON serialization.
66+
if "json" in kwargs:
67+
kwargs["json"] = replace_null(kwargs["json"])
68+
6269
response = super().request(method, url, *args, **kwargs)
6370

6471
return self._handle_response(response)

seam/null.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""The explicit null sentinel used by request params.
2+
3+
Python has a single absence value, ``None``, but the Seam API distinguishes
4+
an omitted param from a param explicitly set to null. For example, in an
5+
update request, an omitted param leaves the current value unchanged,
6+
while a null param unsets the current value.
7+
8+
Since sending null is rarely intended and unsetting a value cannot be undone,
9+
``None`` means the safe option of omitting the param.
10+
Sending null is explicit and always spelled :data:`NULL`.
11+
"""
12+
13+
from collections.abc import Mapping
14+
from typing import Any
15+
16+
17+
class Null:
18+
"""Type of the :data:`NULL` sentinel."""
19+
20+
_instance = None
21+
22+
def __new__(cls):
23+
if cls._instance is None:
24+
cls._instance = super().__new__(cls)
25+
return cls._instance
26+
27+
def __repr__(self):
28+
return "NULL"
29+
30+
def __bool__(self):
31+
return False
32+
33+
34+
NULL: Any = Null()
35+
"""Sentinel for a param explicitly set to null.
36+
37+
Params set to this sentinel are sent as null,
38+
whereas params set to ``None`` are omitted from the request.
39+
40+
Use it wherever the Seam API documents null as a meaningful value, e.g.,
41+
to unset a value in an update request, or to filter by an unset value:
42+
43+
.. code-block:: python
44+
45+
from seam import NULL, Seam
46+
47+
seam = Seam()
48+
49+
# Unsets the name, leaving custom_metadata unchanged.
50+
seam.devices.update(device_id=device_id, name=NULL)
51+
52+
# Lists only the Access Grants which have no access_grant_key.
53+
seam.access_grants.list(access_grant_key=NULL)
54+
55+
This sentinel is typed as ``Any`` so that it may be passed
56+
to any param without a type error.
57+
"""
58+
59+
60+
def is_null(value: Any) -> bool:
61+
"""Returns whether a value is the :data:`NULL` sentinel.
62+
63+
:param value: The value to check
64+
:type value: Any
65+
66+
:returns: Whether the value is the ``NULL`` sentinel"""
67+
68+
return isinstance(value, Null)
69+
70+
71+
def replace_null(value: Any) -> Any:
72+
"""Recursively replaces the :data:`NULL` sentinel with ``None``.
73+
74+
Returns a copy, so the given value is never modified.
75+
Use this to prepare a request payload for JSON serialization,
76+
where ``None`` is serialized to null.
77+
78+
:param value: The value to convert
79+
:type value: Any
80+
81+
:returns: A copy of the value with every ``NULL`` sentinel replaced"""
82+
83+
if is_null(value):
84+
return None
85+
86+
if isinstance(value, Mapping):
87+
return {key: replace_null(item) for key, item in value.items()}
88+
89+
if isinstance(value, list):
90+
return [replace_null(item) for item in value]
91+
92+
if isinstance(value, tuple):
93+
return tuple(replace_null(item) for item in value)
94+
95+
return value

seam/utils/url_search_params_serializer.py

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
1313
Type mapping between the reference implementation and this port:
1414
15-
- JavaScript ``undefined`` is :data:`UNDEFINED`, or simply an absent key.
16-
- JavaScript ``null`` is ``None``.
15+
- JavaScript ``undefined`` is ``None``, or simply an absent key.
16+
- JavaScript ``null`` is :data:`seam.NULL <seam.null.NULL>`.
17+
Python has a single absence value, so ``None`` means the safe option of
18+
omitting the param and sending null is always explicit.
1719
- JavaScript ``string`` is ``str``.
1820
- JavaScript ``boolean`` is ``bool``.
1921
- JavaScript ``number`` is ``float`` or ``int``.
@@ -38,6 +40,8 @@
3840
from typing import Any, Iterator, List, Optional, Sequence, Tuple, Union
3941
from urllib.parse import parse_qsl
4042

43+
from ..null import is_null
44+
4145
Params = Mapping[str, Any]
4246

4347

@@ -60,31 +64,6 @@ def __init__(self, name: str, message: str):
6064
self.name = name
6165

6266

63-
class _Undefined:
64-
"""Type of the :data:`UNDEFINED` sentinel."""
65-
66-
_instance = None
67-
68-
def __new__(cls):
69-
if cls._instance is None:
70-
cls._instance = super().__new__(cls)
71-
return cls._instance
72-
73-
def __repr__(self):
74-
return "UNDEFINED"
75-
76-
def __bool__(self):
77-
return False
78-
79-
80-
UNDEFINED = _Undefined()
81-
"""Sentinel for the absence of a value, equivalent to JavaScript ``undefined``.
82-
83-
Params set to this sentinel are removed, whereas params set to ``None``
84-
are serialized to an empty value. Omitting the key entirely is equivalent.
85-
"""
86-
87-
8867
class UrlSearchParams:
8968
"""A mutable collection of URL search params.
9069
@@ -296,7 +275,7 @@ def _nested_update_url_search_params(
296275

297276
name = ".".join(current_path)
298277

299-
if _is_undefined(value):
278+
if value is None:
300279
continue
301280

302281
if isinstance(value, str) and len(value) == 0:
@@ -328,7 +307,7 @@ def _update_url_search_params_from_array(
328307
"is an array containing the empty string which is unsupported",
329308
)
330309

331-
if any(value is None or _is_undefined(value) for value in values):
310+
if any(value is None or is_null(value) for value in values):
332311
raise UnserializableParamError(
333312
name,
334313
"is an array containing null or undefined values which is unsupported",
@@ -339,7 +318,7 @@ def _update_url_search_params_from_array(
339318

340319

341320
def _serialize(name: str, value: Any) -> str:
342-
if value is None:
321+
if is_null(value):
343322
return ""
344323

345324
if isinstance(value, str):
@@ -364,10 +343,6 @@ def _is_empty_string(value: Any) -> bool:
364343
return isinstance(value, str) and len(value) == 0
365344

366345

367-
def _is_undefined(value: Any) -> bool:
368-
return isinstance(value, _Undefined)
369-
370-
371346
def _format_datetime(value: datetime.datetime) -> str:
372347
if value.tzinfo is None:
373348
value = value.replace(tzinfo=datetime.timezone.utc)

test/null_test.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from collections import OrderedDict
2+
3+
import niquests
4+
import pytest
5+
6+
from seam.client import SeamHttpClient
7+
from seam.null import NULL, Null, is_null, replace_null
8+
9+
10+
def test_null_is_a_singleton():
11+
assert Null() is NULL
12+
assert is_null(NULL)
13+
assert is_null(Null())
14+
15+
16+
def test_null_is_not_none():
17+
assert NULL is not None
18+
assert not is_null(None)
19+
assert not is_null("")
20+
assert not is_null(0)
21+
22+
23+
def test_null_is_falsy():
24+
assert not NULL
25+
26+
27+
def test_null_repr():
28+
assert repr(NULL) == "NULL"
29+
30+
31+
def test_replace_null():
32+
assert replace_null(NULL) is None
33+
assert replace_null(None) is None
34+
assert replace_null("a") == "a"
35+
assert replace_null(0) == 0
36+
assert replace_null(False) is False
37+
38+
39+
def test_replace_null_in_dict():
40+
assert replace_null({"a": NULL, "b": 1, "c": None}) == {
41+
"a": None,
42+
"b": 1,
43+
"c": None,
44+
}
45+
46+
47+
def test_replace_null_in_nested_dict():
48+
assert replace_null({"a": {"b": {"c": NULL}}}) == {"a": {"b": {"c": None}}}
49+
50+
51+
def test_replace_null_in_lists_and_tuples():
52+
assert replace_null(["a", NULL]) == ["a", None]
53+
assert replace_null(("a", NULL)) == ("a", None)
54+
assert replace_null({"a": [{"b": NULL}]}) == {"a": [{"b": None}]}
55+
56+
57+
def test_replace_null_does_not_modify_the_given_value():
58+
params = {"a": NULL, "b": [NULL]}
59+
replace_null(params)
60+
61+
assert params == {"a": NULL, "b": [NULL]}
62+
63+
64+
def test_replace_null_normalizes_mappings_to_dicts():
65+
result = replace_null(OrderedDict([("a", NULL)]))
66+
67+
assert result == {"a": None}
68+
69+
70+
class StubResponse:
71+
status_code = 200
72+
headers = {"content-type": "application/json"}
73+
74+
def json(self):
75+
return {}
76+
77+
78+
@pytest.fixture(name="sent_payloads")
79+
def sent_payloads_fixture(monkeypatch):
80+
payloads = []
81+
82+
# pylint: disable=unused-argument
83+
def request(self, method, url, *args, **kwargs):
84+
payloads.append(kwargs.get("json"))
85+
return StubResponse()
86+
87+
monkeypatch.setattr(niquests.Session, "request", request)
88+
89+
return payloads
90+
91+
92+
def test_client_sends_null_params_as_json_null(sent_payloads):
93+
client = SeamHttpClient(base_url="https://example.com", auth_headers={})
94+
client.post("/devices/update", json={"device_id": "a", "name": NULL})
95+
96+
assert sent_payloads == [{"device_id": "a", "name": None}]
97+
98+
99+
def test_client_sends_nested_null_params_as_json_null(sent_payloads):
100+
client = SeamHttpClient(base_url="https://example.com", auth_headers={})
101+
client.post("/spaces/update", json={"customer_data": {"check_in": NULL}})
102+
103+
assert sent_payloads == [{"customer_data": {"check_in": None}}]
104+
105+
106+
def test_client_passes_through_payloads_without_null_params(sent_payloads):
107+
client = SeamHttpClient(base_url="https://example.com", auth_headers={})
108+
client.post("/devices/update", json={"device_id": "a", "name": "Front Door"})
109+
110+
assert sent_payloads == [{"device_id": "a", "name": "Front Door"}]

0 commit comments

Comments
 (0)