From da8f2f69b77f8dd2eeb3917e61c652e7da461719 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 17:49:24 +0000 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --- README.rst | 50 ++++++++++- seam/client.py | 32 +++++++ seam/null.py | 25 ++++++ test/conftest.py | 24 +++-- test/headers_test.py | 3 +- test/null_test.py | 36 +++++++- test/search_params_test.py | 176 +++++++++++++++++++++++++++++++++++++ 7 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 test/search_params_test.py diff --git a/README.rst b/README.rst index 70517550..5e220f56 100644 --- a/README.rst +++ b/README.rst @@ -47,6 +47,8 @@ Contents * `Action Attempts`_ + * `Setting a Param to Null`_ + * `Pagination`_ * `Manually fetch pages with the next_page_cursor`_ @@ -280,6 +282,49 @@ For example: except SeamActionAttemptTimeoutError as e: print("Door took too long to unlock") +Setting a Param to Null +~~~~~~~~~~~~~~~~~~~~~~~ + +The Seam API tells an omitted param apart from one explicitly set to null. +In an update request, an omitted param leaves the current value unchanged, +while a null param unsets it. + +Python has a single absence value, so this SDK spells the two apart. +A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as null: + +.. code-block:: python + + from seam import NULL, Seam + + seam = Seam() + + # Leaves the name unchanged. + seam.devices.update(device_id="your-device-id", name=None) + + # Unsets the name. + seam.devices.update(device_id="your-device-id", name=NULL) + +Because unsetting a value cannot be undone, ``None`` means the safe option of +omitting the param, and sending null is always explicit. +This is why a param is never sent as null by default, +even though ``None`` is the natural way to spell null in Python. + +``NULL`` behaves the same way in a request body and in a URL search param. +Its type is exported as ``Null`` for annotating your own code: + +.. code-block:: python + + from typing import Optional, Union + + from seam import NULL, Null + + name: Optional[Union[str, Null]] = NULL + +Only use ``NULL`` where the Seam API documents null as a meaningful value, +e.g., to unset a value in an update request. +The generated method signatures do not yet say which params those are, +so a type checker reports ``NULL`` as an invalid argument until they do. + Pagination ~~~~~~~~~~ @@ -562,8 +607,9 @@ A client may percent-encode a few characters differently than ``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``, which the Seam API reads as the same params either way. -A param set to ``None`` is omitted, while a param set to ``seam.NULL`` -is serialized to an empty value, which the Seam API reads as null. +A param set to ``None`` is omitted, while a param set to ``NULL`` +is serialized to an empty value, which the Seam API reads as null, +as described in `Setting a Param to Null`_. A param that cannot be represented raises a ``seam.UnserializableParamError``. The Seam API parses these params with the corresponding `parser`_. diff --git a/seam/client.py b/seam/client.py index 723037f8..f8a0dbe6 100644 --- a/seam/client.py +++ b/seam/client.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import Any, Dict, Optional from importlib.metadata import version import abc @@ -12,6 +13,8 @@ SeamHttpInvalidInputError, SeamHttpUnauthorizedError, ) +from .null import replace_null +from .url_search_params_serializer import serialize_url_search_params SDK_HEADERS = { "seam-sdk-name": "seamapi/python", @@ -102,6 +105,12 @@ def delete(self, url, json=None, **kwargs) -> Any: return self.request("DELETE", url, json=json, **kwargs) def request(self, method, url, *args, **kwargs) -> Any: + if isinstance(kwargs.get("params"), Mapping): + url = with_search_params(url, kwargs.pop("params")) + + if "json" in kwargs: + kwargs["json"] = replace_null(kwargs["json"]) + response = super().request(method, url, *args, **kwargs) return self._handle_response(response) @@ -142,6 +151,29 @@ def _handle_error_response(self, response: Response): raise SeamHttpApiError(error_details, status_code, request_id) +def with_search_params(url: Any, params: Mapping[str, Any]) -> Any: + """Returns the url with the params serialized into its query string. + + The Seam API parses URL search params as complex types, so the query + string is built here and set on the url as-is. Handing the params to + httpx instead would encode them with its own rules. + + :param url: The url of the request + :type url: Any + + :param params: The search params of the request + :type params: Mapping[str, Any] + + :returns: The url carrying the serialized params""" + + query = serialize_url_search_params(params) + + if not query: + return url + + return httpx.URL(url, query=query.encode()) + + def is_api_error_response(response: Response) -> bool: try: content_type = response.headers.get("content-type", "") diff --git a/seam/null.py b/seam/null.py index 42c3b481..66a1cbd6 100644 --- a/seam/null.py +++ b/seam/null.py @@ -10,6 +10,7 @@ Sending null is explicit and always spelled :data:`NULL`. """ +from collections.abc import Mapping, Sequence from typing import Any @@ -60,3 +61,27 @@ def is_null(value: Any) -> bool: :returns: Whether the value is the ``NULL`` sentinel""" return isinstance(value, Null) + + +def replace_null(value: Any) -> Any: + """Returns a copy of a value with every :data:`NULL` sentinel replaced by ``None``. + + The sentinel only distinguishes an explicit null from an omitted param + within this SDK. Once a request body is being serialized, the param is + known to be present, so the sentinel becomes the null that JSON has. + + :param value: The value to copy + :type value: Any + + :returns: The value with each ``NULL`` sentinel replaced by ``None``""" + + if is_null(value): + return None + + if isinstance(value, Mapping): + return {key: replace_null(item) for key, item in value.items()} + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return [replace_null(item) for item in value] + + return value diff --git a/test/conftest.py b/test/conftest.py index 39a47693..0af77f8d 100755 --- a/test/conftest.py +++ b/test/conftest.py @@ -62,6 +62,8 @@ def recording_server(responses): content type inferred from the body, e.g. to serve malformed JSON. Yields the endpoint along with the list of requests received so far. + Each request records the ``method``, the raw request ``target``, the + ``path`` and ``query`` it splits into, the ``headers``, and the ``body``. """ requests = [] @@ -70,21 +72,17 @@ def recording_server(responses): class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" - # pylint: disable-next=invalid-name - def do_GET(self): - self._handle_request() - - # pylint: disable-next=invalid-name - def do_POST(self): - self._handle_request() - def _handle_request(self): content_length = int(self.headers.get("content-length", 0)) raw_body = self.rfile.read(content_length) + path, _, query = self.path.partition("?") requests.append( { - "path": self.path, + "method": self.command, + "target": self.path, + "path": path, + "query": query, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": json.loads(raw_body) if raw_body else None, } @@ -112,6 +110,14 @@ def _handle_request(self): def log_message(self, *args): pass + # Every verb the SDK sends is recorded and answered the same way. + # pylint: disable=invalid-name + do_GET = _handle_request + do_POST = _handle_request + do_PUT = _handle_request + do_PATCH = _handle_request + do_DELETE = _handle_request + server = ThreadingHTTPServer(("localhost", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() diff --git a/test/headers_test.py b/test/headers_test.py index dfb040e0..ffdf34e6 100644 --- a/test/headers_test.py +++ b/test/headers_test.py @@ -17,7 +17,8 @@ def test_seam_sends_default_headers(recording_server): assert len(requests) == 1 [request] = requests - assert request["path"] == f"/devices/get?device_id={device_id}" + assert request["path"] == "/devices/get" + assert request["query"] == f"device_id={device_id}" assert request["body"] is None assert request["headers"]["seam-sdk-name"] == "seamapi/python" diff --git a/test/null_test.py b/test/null_test.py index 83db727c..e329bb3a 100644 --- a/test/null_test.py +++ b/test/null_test.py @@ -1,4 +1,4 @@ -from seam.null import NULL, Null, is_null +from seam.null import NULL, Null, is_null, replace_null def test_null_is_a_singleton(): @@ -20,3 +20,37 @@ def test_null_is_falsy(): def test_null_repr(): assert repr(NULL) == "NULL" + + +def test_replace_null_replaces_the_sentinel_with_none(): + assert replace_null(NULL) is None + + +def test_replace_null_recurses_into_dicts_and_lists(): + assert replace_null( + { + "name": NULL, + "properties": {"code": NULL, "kind": "lock"}, + "codes": [NULL, "1234", [NULL]], + "pairs": (NULL, "1234"), + } + ) == { + "name": None, + "properties": {"code": None, "kind": "lock"}, + "codes": [None, "1234", [None]], + "pairs": [None, "1234"], + } + + +def test_replace_null_leaves_other_values_alone(): + values = [None, "", 0, False, "NULL", {"a": 1}, ["b"]] + + assert replace_null(values) == values + + +def test_replace_null_does_not_mutate_its_argument(): + body = {"name": NULL, "codes": [NULL]} + + replace_null(body) + + assert body == {"name": NULL, "codes": [NULL]} diff --git a/test/search_params_test.py b/test/search_params_test.py new file mode 100644 index 00000000..4113e08e --- /dev/null +++ b/test/search_params_test.py @@ -0,0 +1,176 @@ +from typing import Any, Dict, List + +import pytest + +from seam import NULL, Seam, UnserializableParamError + +DEVICE = {"device": {"device_id": "device1"}} +DEVICES: Dict[str, List[Any]] = {"devices": []} + + +def test_client_serializes_search_params_with_the_seam_standard(recording_server): + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get( + "/devices/list", + params={ + "device_ids": ["device1", "device2"], + "custom_metadata_has": {"tag": "front", "floor": 2}, + "limit": 20, + }, + ) + + [request] = requests + + assert request["method"] == "GET" + assert request["path"] == "/devices/list" + assert request["query"] == ( + "custom_metadata_has.floor=2" + "&custom_metadata_has.tag=front" + "&device_ids=device1" + "&device_ids=device2" + "&limit=20" + ) + + +def test_client_does_not_reencode_the_serialized_search_params(recording_server): + """The Seam standard and httpx disagree on exactly two characters. + + httpx escapes ``*`` and leaves ``~`` alone, so a query it encodes is not + the one the standard defines. Setting the query on the url keeps ours. + """ + + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get("/devices/list", params={"search": "a *~ b"}) + + [request] = requests + + assert request["query"] == "search=a+*%7E+b" + + +def test_client_omits_search_params_set_to_none(recording_server): + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get("/devices/list", params={"search": None, "limit": 20}) + + [request] = requests + + assert request["query"] == "limit=20" + + +def test_client_serializes_search_params_set_to_null(recording_server): + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get("/devices/list", params={"search": NULL, "limit": 20}) + + [request] = requests + + assert request["query"] == "limit=20&search=" + + +def test_client_sends_no_query_string_without_search_params(recording_server): + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get("/devices/list", params={}) + seam.client.get("/devices/list", params={"search": None}) + seam.client.get("/devices/list") + + for request in requests: + assert request["target"] == "/devices/list" + + +def test_client_serializes_search_params_of_every_verb(recording_server): + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get("/devices/list", params={"device_ids": ["device1"]}) + seam.client.delete("/access_codes/delete", params={"sync": True}) + + assert [(request["method"], request["query"]) for request in requests] == [ + ("GET", "device_ids=device1"), + ("DELETE", "sync=true"), + ] + + +def test_client_passes_search_params_it_did_not_serialize_to_httpx(recording_server): + """Params that are not a mapping are left for httpx to encode. + + A caller who serialized the params themselves, e.g. to the pairs of a + ``UrlSearchParams``, has already chosen how they are represented. + """ + + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.get("/devices/list", params=[("device_ids", "device1")]) + + [request] = requests + + assert request["query"] == "device_ids=device1" + + +def test_client_rejects_a_search_param_it_cannot_serialize(recording_server): + with recording_server([(200, DEVICES)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + with pytest.raises(UnserializableParamError): + seam.client.get("/devices/list", params={"search": object()}) + + assert requests == [] + + +def test_client_serializes_null_in_a_json_body_to_null(recording_server): + with recording_server([(200, DEVICE)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.post( + "/devices/update", + json={ + "device_id": "device1", + "name": NULL, + "properties": {"code": NULL}, + "codes": [NULL, "1234"], + }, + ) + + [request] = requests + + assert request["method"] == "POST" + assert request["body"] == { + "device_id": "device1", + "name": None, + "properties": {"code": None}, + "codes": [None, "1234"], + } + + +def test_client_leaves_a_json_body_without_null_unchanged(recording_server): + body = {"device_id": "device1", "name": "Front Door", "limit": 20, "sync": True} + + with recording_server([(200, DEVICE)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.client.post("/devices/update", json=body) + + [request] = requests + + assert request["body"] == body + + +def test_client_serializes_the_search_params_of_a_generated_route(recording_server): + with recording_server([(200, DEVICE)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.devices.get(name="Front Door") + + [request] = requests + + assert request["method"] == "GET" + assert request["path"] == "/devices/get" + assert request["query"] == "name=Front+Door" From df9d93cbf69222b4788528b2e654de8c19362fff Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 13 Aug 2026 11:12:38 -0700 Subject: [PATCH 2/4] Apply suggestions from code review Co-authored-by: Evan Sosenko --- README.rst | 2 +- seam/client.py | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/README.rst b/README.rst index 5e220f56..a1b06408 100644 --- a/README.rst +++ b/README.rst @@ -289,7 +289,7 @@ The Seam API tells an omitted param apart from one explicitly set to null. In an update request, an omitted param leaves the current value unchanged, while a null param unsets it. -Python has a single absence value, so this SDK spells the two apart. +Python has a single nil value `None` which represents an undefined parameter. This SDK provides an explicit null value to send in requests. A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as null: .. code-block:: python diff --git a/seam/client.py b/seam/client.py index f8a0dbe6..f78fefdf 100644 --- a/seam/client.py +++ b/seam/client.py @@ -152,20 +152,6 @@ def _handle_error_response(self, response: Response): def with_search_params(url: Any, params: Mapping[str, Any]) -> Any: - """Returns the url with the params serialized into its query string. - - The Seam API parses URL search params as complex types, so the query - string is built here and set on the url as-is. Handing the params to - httpx instead would encode them with its own rules. - - :param url: The url of the request - :type url: Any - - :param params: The search params of the request - :type params: Mapping[str, Any] - - :returns: The url carrying the serialized params""" - query = serialize_url_search_params(params) if not query: From 2e20c99118ce69f47b548b8f6cfbc1984975f8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 18:22:58 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --- README.rst | 17 ++++-- codegen/layouts/partials/method-signature.hbs | 2 +- codegen/layouts/route.hbs | 3 ++ codegen/lib/class-model.ts | 1 + codegen/lib/handlebars-helpers.ts | 6 +++ codegen/lib/layouts/route.ts | 8 +++ codegen/lib/routes.ts | 1 + seam/routes/access_codes.py | 5 +- seam/routes/access_codes_unmanaged.py | 5 +- seam/routes/access_grants.py | 25 ++++----- seam/routes/access_grants_unmanaged.py | 5 +- seam/routes/access_methods.py | 5 +- seam/routes/acs_credentials.py | 5 +- seam/routes/acs_encoders.py | 5 +- seam/routes/acs_entrances.py | 9 ++-- seam/routes/acs_users.py | 9 ++-- seam/routes/action_attempts.py | 5 +- seam/routes/connect_webviews.py | 5 +- seam/routes/connected_accounts.py | 5 +- seam/routes/devices.py | 13 ++--- seam/routes/devices_unmanaged.py | 5 +- seam/routes/spaces.py | 5 +- seam/routes/thermostats.py | 53 ++++++++++--------- seam/routes/thermostats_schedules.py | 9 ++-- seam/routes/user_identities.py | 37 ++++++------- seam/routes/user_identities_unmanaged.py | 5 +- seam/routes/workspaces.py | 5 +- test/nullable_param_test.py | 52 ++++++++++++++++++ 28 files changed, 204 insertions(+), 106 deletions(-) create mode 100644 test/nullable_param_test.py diff --git a/README.rst b/README.rst index a1b06408..d93f018a 100644 --- a/README.rst +++ b/README.rst @@ -290,7 +290,7 @@ In an update request, an omitted param leaves the current value unchanged, while a null param unsets it. Python has a single nil value `None` which represents an undefined parameter. This SDK provides an explicit null value to send in requests. -A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as null: +A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as `null`: .. code-block:: python @@ -320,10 +320,17 @@ Its type is exported as ``Null`` for annotating your own code: name: Optional[Union[str, Null]] = NULL -Only use ``NULL`` where the Seam API documents null as a meaningful value, -e.g., to unset a value in an update request. -The generated method signatures do not yet say which params those are, -so a type checker reports ``NULL`` as an invalid argument until they do. +Only params the Seam API documents as nullable accept ``NULL``. +The generated method signatures say which ones those are, +so a type checker rejects ``NULL`` anywhere else: + +.. code-block:: python + + # name is nullable, so it may be unset. + seam.devices.update(device_id="your-device-id", name=NULL) + + # is_managed is not, so this fails the type check. + seam.devices.update(device_id="your-device-id", is_managed=NULL) Pagination ~~~~~~~~~~ diff --git a/codegen/layouts/partials/method-signature.hbs b/codegen/layouts/partials/method-signature.hbs index 10977db0..4e58dbd1 100644 --- a/codegen/layouts/partials/method-signature.hbs +++ b/codegen/layouts/partials/method-signature.hbs @@ -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}} \ No newline at end of file +{{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}} \ No newline at end of file diff --git a/codegen/layouts/route.hbs b/codegen/layouts/route.hbs index afaf2b43..32540f82 100644 --- a/codegen/layouts/route.hbs +++ b/codegen/layouts/route.hbs @@ -2,6 +2,9 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient from ..route import route_metadata +{{#if importNull}} +from ..null import Null +{{/if}} {{#if resourceClasses}} from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}}) {{/if}} diff --git a/codegen/lib/class-model.ts b/codegen/lib/class-model.ts index 9ecca230..d9333b65 100644 --- a/codegen/lib/class-model.ts +++ b/codegen/lib/class-model.ts @@ -4,6 +4,7 @@ export interface ClassMethodParameter { name: string type: string + isNullable: boolean description: string isDeprecated: boolean deprecationMessage: string diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index dd4abeda..4b8a4227 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -53,6 +53,12 @@ export const indent = (value: string, spaces: number): string => export const pythonIdentifier = (name: string): string => PYTHON_KEYWORDS.has(name) ? `${name}_` : name +// A param the API documents as nullable may be set to the NULL sentinel, which +// the client serializes to null. Params that are merely optional may not: they +// are omitted by passing None, and sending null would unset a value instead. +export const nullableType = (type: string, isNullable: boolean): string => + isNullable ? `Union[${type}, Null]` : type + export const isListType = (type: string): boolean => type.startsWith('List[') export const listItemType = (type: string): string => type.slice(5, -1) diff --git a/codegen/lib/layouts/route.ts b/codegen/lib/layouts/route.ts index be784fdc..00c57863 100644 --- a/codegen/lib/layouts/route.ts +++ b/codegen/lib/layouts/route.ts @@ -23,6 +23,7 @@ export interface MethodLayoutContext { params: Array<{ name: string type: string + isNullable: boolean description: string isDeprecated: boolean deprecationMessage: string @@ -53,6 +54,7 @@ export interface RouteLayoutContext { module: string }> importResolveActionAttempt: boolean + importNull: boolean methods: MethodLayoutContext[] } @@ -83,6 +85,7 @@ export const getMethodLayoutContext = ( params: sortClassMethodParameters(method.parameters).map((parameter) => ({ name: parameter.name, type: parameter.type, + isNullable: parameter.isNullable, description: parameter.description, isDeprecated: parameter.isDeprecated, deprecationMessage: parameter.deprecationMessage, @@ -108,6 +111,10 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => { const abstractClassName = `Abstract${cls.name}` const methods = cls.methods.map(getMethodLayoutContext) + const importNull = methods.some(({ params }) => + params.some(({ isNullable }) => isNullable), + ) + return { className: cls.name, abstractClassName, @@ -131,6 +138,7 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => { module: `${cls.namespace}_${identifier.namespace}`, })), importResolveActionAttempt, + importNull, methods, } } diff --git a/codegen/lib/routes.ts b/codegen/lib/routes.ts index 2d5f33d1..cbc0a98a 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -99,6 +99,7 @@ export const routes = ( parameters: endpoint.request.parameters.map((parameter) => ({ name: parameter.name, type: mapParameterToPythonType(parameter), + isNullable: parameter.isNullable, description: parameter.description, isDeprecated: parameter.isDeprecated, deprecationMessage: parameter.deprecationMessage, diff --git a/seam/routes/access_codes.py b/seam/routes/access_codes.py index 82231033..3f5053f5 100644 --- a/seam/routes/access_codes.py +++ b/seam/routes/access_codes.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import AccessCode from .access_codes_simulate import AbstractAccessCodesSimulate, AccessCodesSimulate from .access_codes_unmanaged import AbstractAccessCodesUnmanaged, AccessCodesUnmanaged @@ -205,7 +206,7 @@ def list( customer_key: Optional[str] = None, device_id: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identifier_key: Optional[str] = None, ) -> List[AccessCode]: @@ -704,7 +705,7 @@ def list( customer_key: Optional[str] = None, device_id: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identifier_key: Optional[str] = None, ) -> List[AccessCode]: diff --git a/seam/routes/access_codes_unmanaged.py b/seam/routes/access_codes_unmanaged.py index d8f35bc6..8a6e67d4 100644 --- a/seam/routes/access_codes_unmanaged.py +++ b/seam/routes/access_codes_unmanaged.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import UnmanagedAccessCode @@ -71,7 +72,7 @@ def list( *, device_id: str, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identifier_key: Optional[str] = None, ) -> List[UnmanagedAccessCode]: @@ -253,7 +254,7 @@ def list( *, device_id: str, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identifier_key: Optional[str] = None, ) -> List[UnmanagedAccessCode]: diff --git a/seam/routes/access_grants.py b/seam/routes/access_grants.py index dfad2ccd..fbc7685a 100644 --- a/seam/routes/access_grants.py +++ b/seam/routes/access_grants.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import AccessGrant, Batch from .access_grants_unmanaged import ( AbstractAccessGrantsUnmanaged, @@ -27,10 +28,10 @@ def create( acs_entrance_ids: Optional[List[str]] = None, customization_profile_id: Optional[str] = None, device_ids: Optional[List[str]] = None, - ends_at: Optional[str] = None, + ends_at: Optional[Union[str, Null]] = None, location: Optional[Dict[str, Any]] = None, location_ids: Optional[List[str]] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, reservation_key: Optional[str] = None, space_ids: Optional[List[str]] = None, space_keys: Optional[List[str]] = None, @@ -130,14 +131,14 @@ def list( *, access_code_id: Optional[str] = None, access_grant_ids: Optional[List[str]] = None, - access_grant_key: Optional[str] = None, + access_grant_key: Optional[Union[str, Null]] = None, acs_entrance_id: Optional[str] = None, acs_system_id: Optional[str] = None, customer_key: Optional[str] = None, device_id: Optional[str] = None, limit: Optional[float] = None, location_id: Optional[str] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, reservation_key: Optional[str] = None, space_id: Optional[str] = None, user_identity_id: Optional[str] = None, @@ -194,8 +195,8 @@ def update( *, access_grant_id: Optional[str] = None, access_grant_key: Optional[str] = None, - ends_at: Optional[str] = None, - name: Optional[str] = None, + ends_at: Optional[Union[str, Null]] = None, + name: Optional[Union[str, Null]] = None, starts_at: Optional[str] = None, ) -> None: """Updates an existing Access Grant's time window. @@ -237,10 +238,10 @@ def create( acs_entrance_ids: Optional[List[str]] = None, customization_profile_id: Optional[str] = None, device_ids: Optional[List[str]] = None, - ends_at: Optional[str] = None, + ends_at: Optional[Union[str, Null]] = None, location: Optional[Dict[str, Any]] = None, location_ids: Optional[List[str]] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, reservation_key: Optional[str] = None, space_ids: Optional[List[str]] = None, space_keys: Optional[List[str]] = None, @@ -434,14 +435,14 @@ def list( *, access_code_id: Optional[str] = None, access_grant_ids: Optional[List[str]] = None, - access_grant_key: Optional[str] = None, + access_grant_key: Optional[Union[str, Null]] = None, acs_entrance_id: Optional[str] = None, acs_system_id: Optional[str] = None, customer_key: Optional[str] = None, device_id: Optional[str] = None, limit: Optional[float] = None, location_id: Optional[str] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, reservation_key: Optional[str] = None, space_id: Optional[str] = None, user_identity_id: Optional[str] = None, @@ -551,8 +552,8 @@ def update( *, access_grant_id: Optional[str] = None, access_grant_key: Optional[str] = None, - ends_at: Optional[str] = None, - name: Optional[str] = None, + ends_at: Optional[Union[str, Null]] = None, + name: Optional[Union[str, Null]] = None, starts_at: Optional[str] = None, ) -> None: """Updates an existing Access Grant's time window. diff --git a/seam/routes/access_grants_unmanaged.py b/seam/routes/access_grants_unmanaged.py index 78eb8918..843709e8 100644 --- a/seam/routes/access_grants_unmanaged.py +++ b/seam/routes/access_grants_unmanaged.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import UnmanagedAccessGrant @@ -25,7 +26,7 @@ def list( acs_entrance_id: Optional[str] = None, acs_system_id: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, reservation_key: Optional[str] = None, user_identity_id: Optional[str] = None, ) -> List[UnmanagedAccessGrant]: @@ -113,7 +114,7 @@ def list( acs_entrance_id: Optional[str] = None, acs_system_id: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, reservation_key: Optional[str] = None, user_identity_id: Optional[str] = None, ) -> List[UnmanagedAccessGrant]: diff --git a/seam/routes/access_methods.py b/seam/routes/access_methods.py index 8c9ca9ee..915c4773 100644 --- a/seam/routes/access_methods.py +++ b/seam/routes/access_methods.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ActionAttempt, AccessMethod, Batch from .access_methods_unmanaged import ( AbstractAccessMethodsUnmanaged, @@ -120,7 +121,7 @@ def list( acs_entrance_id: Optional[str] = None, device_id: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, space_id: Optional[str] = None, ) -> List[AccessMethod]: """Lists all access methods, usually filtered by Access Grant. @@ -393,7 +394,7 @@ def list( acs_entrance_id: Optional[str] = None, device_id: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, space_id: Optional[str] = None, ) -> List[AccessMethod]: """Lists all access methods, usually filtered by Access Grant. diff --git a/seam/routes/acs_credentials.py b/seam/routes/acs_credentials.py index 68a56846..ceff9409 100644 --- a/seam/routes/acs_credentials.py +++ b/seam/routes/acs_credentials.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import AcsCredential, AcsEntrance @@ -107,7 +108,7 @@ def list( created_before: Optional[str] = None, is_multi_phone_sync_credential: Optional[bool] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, ) -> List[AcsCredential]: """Returns a list of all `credentials `_. @@ -382,7 +383,7 @@ def list( created_before: Optional[str] = None, is_multi_phone_sync_credential: Optional[bool] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, ) -> List[AcsCredential]: """Returns a list of all `credentials `_. diff --git a/seam/routes/acs_encoders.py b/seam/routes/acs_encoders.py index 194008aa..5b6d9c8a 100644 --- a/seam/routes/acs_encoders.py +++ b/seam/routes/acs_encoders.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ActionAttempt, AcsEncoder from .acs_encoders_simulate import AbstractAcsEncodersSimulate, AcsEncodersSimulate from ..modules.action_attempts import resolve_action_attempt @@ -57,7 +58,7 @@ def list( acs_system_ids: Optional[List[str]] = None, acs_encoder_ids: Optional[List[str]] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, ) -> List[AcsEncoder]: """Returns a list of all `encoders `_. @@ -220,7 +221,7 @@ def list( acs_system_ids: Optional[List[str]] = None, acs_encoder_ids: Optional[List[str]] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, ) -> List[AcsEncoder]: """Returns a list of all `encoders `_. diff --git a/seam/routes/acs_entrances.py b/seam/routes/acs_entrances.py index 353929ca..ddb863d4 100644 --- a/seam/routes/acs_entrances.py +++ b/seam/routes/acs_entrances.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import AcsEntrance, AcsCredential, ActionAttempt from ..modules.action_attempts import resolve_action_attempt @@ -49,8 +50,8 @@ def list( connected_account_id: Optional[str] = None, customer_key: Optional[str] = None, limit: Optional[int] = None, - location_id: Optional[str] = None, - page_cursor: Optional[str] = None, + location_id: Optional[Union[str, Null]] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_id: Optional[str] = None, ) -> List[AcsEntrance]: @@ -200,8 +201,8 @@ def list( connected_account_id: Optional[str] = None, customer_key: Optional[str] = None, limit: Optional[int] = None, - location_id: Optional[str] = None, - page_cursor: Optional[str] = None, + location_id: Optional[Union[str, Null]] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_id: Optional[str] = None, ) -> List[AcsEntrance]: diff --git a/seam/routes/acs_users.py b/seam/routes/acs_users.py index 6e905b5e..1da621eb 100644 --- a/seam/routes/acs_users.py +++ b/seam/routes/acs_users.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import AcsUser, AcsEntrance @@ -103,7 +104,7 @@ def list( acs_system_id: Optional[str] = None, created_before: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identity_email_address: Optional[str] = None, user_identity_id: Optional[str] = None, @@ -231,7 +232,7 @@ def unsuspend( def update( self, *, - access_schedule: Optional[Dict[str, Any]] = None, + access_schedule: Optional[Union[Dict[str, Any], Null]] = None, acs_system_id: Optional[str] = None, acs_user_id: Optional[str] = None, email: Optional[str] = None, @@ -444,7 +445,7 @@ def list( acs_system_id: Optional[str] = None, created_before: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identity_email_address: Optional[str] = None, user_identity_id: Optional[str] = None, @@ -691,7 +692,7 @@ def unsuspend( def update( self, *, - access_schedule: Optional[Dict[str, Any]] = None, + access_schedule: Optional[Union[Dict[str, Any], Null]] = None, acs_system_id: Optional[str] = None, acs_user_id: Optional[str] = None, email: Optional[str] = None, diff --git a/seam/routes/action_attempts.py b/seam/routes/action_attempts.py index 977ad01b..8f0c221c 100644 --- a/seam/routes/action_attempts.py +++ b/seam/routes/action_attempts.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ActionAttempt from ..modules.action_attempts import resolve_action_attempt @@ -33,7 +34,7 @@ def list( action_attempt_ids: Optional[List[str]] = None, device_id: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, ) -> List[ActionAttempt]: """Returns a list of the `action attempts `_ that you specify as an array of ``action_attempt_id``s. @@ -105,7 +106,7 @@ def list( action_attempt_ids: Optional[List[str]] = None, device_id: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, ) -> List[ActionAttempt]: """Returns a list of the `action attempts `_ that you specify as an array of ``action_attempt_id``s. diff --git a/seam/routes/connect_webviews.py b/seam/routes/connect_webviews.py index 2310a432..053ed685 100644 --- a/seam/routes/connect_webviews.py +++ b/seam/routes/connect_webviews.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ConnectWebview @@ -84,7 +85,7 @@ def list( custom_metadata_has: Optional[Dict[str, Any]] = None, customer_key: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identifier_key: Optional[str] = None, ) -> List[ConnectWebview]: @@ -253,7 +254,7 @@ def list( custom_metadata_has: Optional[Dict[str, Any]] = None, customer_key: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identifier_key: Optional[str] = None, ) -> List[ConnectWebview]: diff --git a/seam/routes/connected_accounts.py b/seam/routes/connected_accounts.py index 1d8a6a6f..7c50bfd2 100644 --- a/seam/routes/connected_accounts.py +++ b/seam/routes/connected_accounts.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ConnectedAccount from .connected_accounts_simulate import ( AbstractConnectedAccountsSimulate, @@ -51,7 +52,7 @@ def list( custom_metadata_has: Optional[Dict[str, Any]] = None, customer_key: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_id: Optional[str] = None, user_identifier_key: Optional[str] = None, @@ -196,7 +197,7 @@ def list( custom_metadata_has: Optional[Dict[str, Any]] = None, customer_key: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_id: Optional[str] = None, user_identifier_key: Optional[str] = None, diff --git a/seam/routes/devices.py b/seam/routes/devices.py index 8fe8841c..e1b6dfbb 100644 --- a/seam/routes/devices.py +++ b/seam/routes/devices.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import Device, DeviceProvider from .devices_simulate import AbstractDevicesSimulate, DevicesSimulate from .devices_unmanaged import AbstractDevicesUnmanaged, DevicesUnmanaged @@ -51,10 +52,10 @@ def list( device_types: Optional[List[str]] = None, limit: Optional[float] = None, manufacturer: Optional[str] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_id: Optional[str] = None, - unstable_location_id: Optional[str] = None, + unstable_location_id: Optional[Union[str, Null]] = None, user_identifier_key: Optional[str] = None, ) -> List[Device]: """Returns a list of all `devices `_. @@ -126,7 +127,7 @@ def update( backup_access_code_pool_enabled: Optional[bool] = None, custom_metadata: Optional[Dict[str, Any]] = None, is_managed: Optional[bool] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, properties: Optional[Dict[str, Any]] = None, ) -> None: """Updates a specified `device `_. @@ -212,10 +213,10 @@ def list( device_types: Optional[List[str]] = None, limit: Optional[float] = None, manufacturer: Optional[str] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_id: Optional[str] = None, - unstable_location_id: Optional[str] = None, + unstable_location_id: Optional[Union[str, Null]] = None, user_identifier_key: Optional[str] = None, ) -> List[Device]: """Returns a list of all `devices `_. @@ -353,7 +354,7 @@ def update( backup_access_code_pool_enabled: Optional[bool] = None, custom_metadata: Optional[Dict[str, Any]] = None, is_managed: Optional[bool] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, properties: Optional[Dict[str, Any]] = None, ) -> None: """Updates a specified `device `_. diff --git a/seam/routes/devices_unmanaged.py b/seam/routes/devices_unmanaged.py index 4b8b5792..12987d60 100644 --- a/seam/routes/devices_unmanaged.py +++ b/seam/routes/devices_unmanaged.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import UnmanagedDevice @@ -40,7 +41,7 @@ def list( device_types: Optional[List[str]] = None, limit: Optional[float] = None, manufacturer: Optional[str] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, ) -> List[UnmanagedDevice]: """Returns a list of all `unmanaged devices `_. @@ -156,7 +157,7 @@ def list( device_types: Optional[List[str]] = None, limit: Optional[float] = None, manufacturer: Optional[str] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, ) -> List[UnmanagedDevice]: """Returns a list of all `unmanaged devices `_. diff --git a/seam/routes/spaces.py b/seam/routes/spaces.py index b72ea972..56f80f37 100644 --- a/seam/routes/spaces.py +++ b/seam/routes/spaces.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import Space, Batch @@ -129,7 +130,7 @@ def list( *, customer_key: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_key: Optional[str] = None, ) -> List[Space]: @@ -465,7 +466,7 @@ def list( *, customer_key: Optional[str] = None, limit: Optional[float] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, space_key: Optional[str] = None, ) -> List[Space]: diff --git a/seam/routes/thermostats.py b/seam/routes/thermostats.py index b5ec077f..231090d3 100644 --- a/seam/routes/thermostats.py +++ b/seam/routes/thermostats.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ActionAttempt, Device from .thermostats_daily_programs import ( AbstractThermostatsDailyPrograms, @@ -89,7 +90,7 @@ def create_climate_preset( heating_set_point_fahrenheit: Optional[float] = None, hvac_mode_setting: Optional[str] = None, manual_override_allowed: Optional[bool] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, ) -> None: """Creates a `climate preset `_ for a specified `thermostat `_. @@ -306,10 +307,10 @@ def set_temperature_threshold( self, *, device_id: str, - lower_limit_celsius: Optional[float] = None, - lower_limit_fahrenheit: Optional[float] = None, - upper_limit_celsius: Optional[float] = None, - upper_limit_fahrenheit: Optional[float] = None, + lower_limit_celsius: Optional[Union[float, Null]] = None, + lower_limit_fahrenheit: Optional[Union[float, Null]] = None, + upper_limit_celsius: Optional[Union[float, Null]] = None, + upper_limit_fahrenheit: Optional[Union[float, Null]] = None, ) -> None: """Sets a `temperature threshold `_ for a specified thermostat. Seam emits a ``thermostat.temperature_threshold_exceeded`` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. @@ -341,7 +342,7 @@ def update_climate_preset( heating_set_point_fahrenheit: Optional[float] = None, hvac_mode_setting: Optional[str] = None, manual_override_allowed: Optional[bool] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, ) -> None: """Updates a specified `climate preset `_ for a specified `thermostat `_. @@ -377,13 +378,13 @@ def update_weekly_program( self, *, device_id: str, - friday_program_id: Optional[str] = None, - monday_program_id: Optional[str] = None, - saturday_program_id: Optional[str] = None, - sunday_program_id: Optional[str] = None, - thursday_program_id: Optional[str] = None, - tuesday_program_id: Optional[str] = None, - wednesday_program_id: Optional[str] = None, + friday_program_id: Optional[Union[str, Null]] = None, + monday_program_id: Optional[Union[str, Null]] = None, + saturday_program_id: Optional[Union[str, Null]] = None, + sunday_program_id: Optional[Union[str, Null]] = None, + thursday_program_id: Optional[Union[str, Null]] = None, + tuesday_program_id: Optional[Union[str, Null]] = None, + wednesday_program_id: Optional[Union[str, Null]] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None, ) -> ActionAttempt: """Updates the thermostat weekly program for a thermostat device. To configure a weekly program, specify the ID of the daily program that you want to use for each day of the week. When you update a weekly program, the set of programs that you specify overwrites any previous weekly program for the thermostat. @@ -554,7 +555,7 @@ def create_climate_preset( heating_set_point_fahrenheit: Optional[float] = None, hvac_mode_setting: Optional[str] = None, manual_override_allowed: Optional[bool] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, ) -> None: """Creates a `climate preset `_ for a specified `thermostat `_. @@ -1012,10 +1013,10 @@ def set_temperature_threshold( self, *, device_id: str, - lower_limit_celsius: Optional[float] = None, - lower_limit_fahrenheit: Optional[float] = None, - upper_limit_celsius: Optional[float] = None, - upper_limit_fahrenheit: Optional[float] = None, + lower_limit_celsius: Optional[Union[float, Null]] = None, + lower_limit_fahrenheit: Optional[Union[float, Null]] = None, + upper_limit_celsius: Optional[Union[float, Null]] = None, + upper_limit_fahrenheit: Optional[Union[float, Null]] = None, ) -> None: """Sets a `temperature threshold `_ for a specified thermostat. Seam emits a ``thermostat.temperature_threshold_exceeded`` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. @@ -1071,7 +1072,7 @@ def update_climate_preset( heating_set_point_fahrenheit: Optional[float] = None, hvac_mode_setting: Optional[str] = None, manual_override_allowed: Optional[bool] = None, - name: Optional[str] = None, + name: Optional[Union[str, Null]] = None, ) -> None: """Updates a specified `climate preset `_ for a specified `thermostat `_. @@ -1145,13 +1146,13 @@ def update_weekly_program( self, *, device_id: str, - friday_program_id: Optional[str] = None, - monday_program_id: Optional[str] = None, - saturday_program_id: Optional[str] = None, - sunday_program_id: Optional[str] = None, - thursday_program_id: Optional[str] = None, - tuesday_program_id: Optional[str] = None, - wednesday_program_id: Optional[str] = None, + friday_program_id: Optional[Union[str, Null]] = None, + monday_program_id: Optional[Union[str, Null]] = None, + saturday_program_id: Optional[Union[str, Null]] = None, + sunday_program_id: Optional[Union[str, Null]] = None, + thursday_program_id: Optional[Union[str, Null]] = None, + tuesday_program_id: Optional[Union[str, Null]] = None, + wednesday_program_id: Optional[Union[str, Null]] = None, wait_for_action_attempt: Optional[Union[bool, Dict[str, float]]] = None, ) -> ActionAttempt: """Updates the thermostat weekly program for a thermostat device. To configure a weekly program, specify the ID of the daily program that you want to use for each day of the week. When you update a weekly program, the set of programs that you specify overwrites any previous weekly program for the thermostat. diff --git a/seam/routes/thermostats_schedules.py b/seam/routes/thermostats_schedules.py index d708149e..7c4787ad 100644 --- a/seam/routes/thermostats_schedules.py +++ b/seam/routes/thermostats_schedules.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ThermostatSchedule @@ -16,7 +17,7 @@ def create( ends_at: str, starts_at: str, is_override_allowed: Optional[bool] = None, - max_override_period_minutes: Optional[int] = None, + max_override_period_minutes: Optional[Union[int, Null]] = None, name: Optional[str] = None, ) -> ThermostatSchedule: """Creates a new `thermostat schedule `_ for a specified `thermostat `_. @@ -83,7 +84,7 @@ def update( climate_preset_key: Optional[str] = None, ends_at: Optional[str] = None, is_override_allowed: Optional[bool] = None, - max_override_period_minutes: Optional[int] = None, + max_override_period_minutes: Optional[Union[int, Null]] = None, name: Optional[str] = None, starts_at: Optional[str] = None, ) -> None: @@ -125,7 +126,7 @@ def create( ends_at: str, starts_at: str, is_override_allowed: Optional[bool] = None, - max_override_period_minutes: Optional[int] = None, + max_override_period_minutes: Optional[Union[int, Null]] = None, name: Optional[str] = None, ) -> ThermostatSchedule: """Creates a new `thermostat schedule `_ for a specified `thermostat `_. @@ -272,7 +273,7 @@ def update( climate_preset_key: Optional[str] = None, ends_at: Optional[str] = None, is_override_allowed: Optional[bool] = None, - max_override_period_minutes: Optional[int] = None, + max_override_period_minutes: Optional[Union[int, Null]] = None, name: Optional[str] = None, starts_at: Optional[str] = None, ) -> None: diff --git a/seam/routes/user_identities.py b/seam/routes/user_identities.py index ab7442b2..a9107bc6 100644 --- a/seam/routes/user_identities.py +++ b/seam/routes/user_identities.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ( UserIdentity, InstantKey, @@ -51,10 +52,10 @@ def create( self, *, acs_system_ids: Optional[List[str]] = None, - email_address: Optional[str] = None, - full_name: Optional[str] = None, - phone_number: Optional[str] = None, - user_identity_key: Optional[str] = None, + email_address: Optional[Union[str, Null]] = None, + full_name: Optional[Union[str, Null]] = None, + phone_number: Optional[Union[str, Null]] = None, + user_identity_key: Optional[Union[str, Null]] = None, ) -> UserIdentity: """Creates a new `user identity `_. @@ -137,7 +138,7 @@ def list( created_before: Optional[str] = None, credential_manager_acs_system_id: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identity_ids: Optional[List[str]] = None, ) -> List[UserIdentity]: @@ -229,10 +230,10 @@ def update( self, *, user_identity_id: str, - email_address: Optional[str] = None, - full_name: Optional[str] = None, - phone_number: Optional[str] = None, - user_identity_key: Optional[str] = None, + email_address: Optional[Union[str, Null]] = None, + full_name: Optional[Union[str, Null]] = None, + phone_number: Optional[Union[str, Null]] = None, + user_identity_key: Optional[Union[str, Null]] = None, ) -> None: """Updates a specified `user identity `_. @@ -312,10 +313,10 @@ def create( self, *, acs_system_ids: Optional[List[str]] = None, - email_address: Optional[str] = None, - full_name: Optional[str] = None, - phone_number: Optional[str] = None, - user_identity_key: Optional[str] = None, + email_address: Optional[Union[str, Null]] = None, + full_name: Optional[Union[str, Null]] = None, + phone_number: Optional[Union[str, Null]] = None, + user_identity_key: Optional[Union[str, Null]] = None, ) -> UserIdentity: """Creates a new `user identity `_. @@ -487,7 +488,7 @@ def list( created_before: Optional[str] = None, credential_manager_acs_system_id: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, user_identity_ids: Optional[List[str]] = None, ) -> List[UserIdentity]: @@ -704,10 +705,10 @@ def update( self, *, user_identity_id: str, - email_address: Optional[str] = None, - full_name: Optional[str] = None, - phone_number: Optional[str] = None, - user_identity_key: Optional[str] = None, + email_address: Optional[Union[str, Null]] = None, + full_name: Optional[Union[str, Null]] = None, + phone_number: Optional[Union[str, Null]] = None, + user_identity_key: Optional[Union[str, Null]] = None, ) -> None: """Updates a specified `user identity `_. diff --git a/seam/routes/user_identities_unmanaged.py b/seam/routes/user_identities_unmanaged.py index 31627ad2..b177177a 100644 --- a/seam/routes/user_identities_unmanaged.py +++ b/seam/routes/user_identities_unmanaged.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import UnmanagedUserIdentity @@ -24,7 +25,7 @@ def list( *, created_before: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, ) -> List[UnmanagedUserIdentity]: """Returns a list of all unmanaged `user identities `_ (where is_managed = false). @@ -104,7 +105,7 @@ def list( *, created_before: Optional[str] = None, limit: Optional[int] = None, - page_cursor: Optional[str] = None, + page_cursor: Optional[Union[str, Null]] = None, search: Optional[str] = None, ) -> List[UnmanagedUserIdentity]: """Returns a list of all unmanaged `user identities `_ (where is_managed = false). diff --git a/seam/routes/workspaces.py b/seam/routes/workspaces.py index 09bbb529..01ee6655 100644 --- a/seam/routes/workspaces.py +++ b/seam/routes/workspaces.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import Workspace, ActionAttempt from ..modules.action_attempts import resolve_action_attempt @@ -14,7 +15,7 @@ def create( *, name: str, company_name: Optional[str] = None, - connect_partner_name: Optional[str] = None, + connect_partner_name: Optional[Union[str, Null]] = None, connect_webview_customization: Optional[Dict[str, Any]] = None, is_sandbox: Optional[bool] = None, organization_id: Optional[str] = None, @@ -116,7 +117,7 @@ def create( *, name: str, company_name: Optional[str] = None, - connect_partner_name: Optional[str] = None, + connect_partner_name: Optional[Union[str, Null]] = None, connect_webview_customization: Optional[Dict[str, Any]] = None, is_sandbox: Optional[bool] = None, organization_id: Optional[str] = None, diff --git a/test/nullable_param_test.py b/test/nullable_param_test.py new file mode 100644 index 00000000..9557020d --- /dev/null +++ b/test/nullable_param_test.py @@ -0,0 +1,52 @@ +"""Tests that generated params accept the NULL sentinel where the API allows it. + +These assertions are about types as much as behavior: the SDK is type checked, +so a nullable param losing its ``Null`` type, or a param that is merely +optional gaining one, fails the type check rather than any assertion here. +""" + +from seam import NULL, Seam + +DEVICE = {"device": {"device_id": "device1"}} + + +def test_a_nullable_param_is_sent_as_null(recording_server): + with recording_server([(200, DEVICE)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + # The API documents name as nullable, so it may be unset. + seam.devices.update(device_id="device1", name=NULL) + + [request] = requests + + assert request["body"] == {"device_id": "device1", "name": None} + + +def test_a_nullable_number_param_is_sent_as_null(recording_server): + with recording_server([(200, {})]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.thermostats.set_temperature_threshold( + device_id="device1", + lower_limit_celsius=NULL, + upper_limit_celsius=20.5, + ) + + [request] = requests + + assert request["body"] == { + "device_id": "device1", + "lower_limit_celsius": None, + "upper_limit_celsius": 20.5, + } + + +def test_an_omitted_param_is_not_sent(recording_server): + with recording_server([(200, DEVICE)]) as (endpoint, requests): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + + seam.devices.update(device_id="device1", name=None) + + [request] = requests + + assert request["body"] == {"device_id": "device1"} From c51f9011d61d1d6dd0f9caed91f3723f364796fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 18:26:46 +0000 Subject: [PATCH 4/4] test: name the search param tests for what they do Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --- test/search_params_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/search_params_test.py b/test/search_params_test.py index 4113e08e..79545daf 100644 --- a/test/search_params_test.py +++ b/test/search_params_test.py @@ -8,7 +8,7 @@ DEVICES: Dict[str, List[Any]] = {"devices": []} -def test_client_serializes_search_params_with_the_seam_standard(recording_server): +def test_client_serializes_search_params(recording_server): with recording_server([(200, DEVICES)]) as (endpoint, requests): seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) @@ -35,10 +35,10 @@ def test_client_serializes_search_params_with_the_seam_standard(recording_server def test_client_does_not_reencode_the_serialized_search_params(recording_server): - """The Seam standard and httpx disagree on exactly two characters. + """The serializer and httpx disagree on exactly two characters. httpx escapes ``*`` and leaves ``~`` alone, so a query it encodes is not - the one the standard defines. Setting the query on the url keeps ours. + the one the serializer produced. Setting the query on the url keeps ours. """ with recording_server([(200, DEVICES)]) as (endpoint, requests):