From 4f0e1b80b5857d1254924fcf61daa2dd066087da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:22:27 +0000 Subject: [PATCH 1/3] feat: implement the URL search params serialization standard Port @seamapi/url-search-params-serializer to Python so the SDK can serialize objects to URL search params for HTTP GET requests. Output is byte-for-byte identical to the reference implementation: - Values are encoded with the application/x-www-form-urlencoded serializer, which differs from urllib in its treatment of "*" and "~". - Params are sorted by name, compared by UTF-16 code unit. - Floats are formatted using the ECMAScript Number::toString algorithm, which differs from repr for integral floats and around the exponent notation thresholds. Python has no undefined, so UNDEFINED is provided as the sentinel for a removed param, while None serializes to an empty value as null does. Temporal.Instant and Date both map to datetime, where a naive datetime is interpreted as UTC and microseconds are truncated to millisecond precision. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --- README.rst | 45 ++ seam/__init__.py | 7 + seam/utils/url_search_params_serializer.py | 460 +++++++++++++++++++++ test/url_search_params_serializer_test.py | 448 ++++++++++++++++++++ 4 files changed, 960 insertions(+) create mode 100644 seam/utils/url_search_params_serializer.py create mode 100644 test/url_search_params_serializer_test.py diff --git a/README.rst b/README.rst index d3f7c52e..1678fdaa 100644 --- a/README.rst +++ b/README.rst @@ -65,6 +65,8 @@ Contents * `Setting the endpoint`_ + * `Serializing URL search params`_ + * `Development and Testing`_ * `Quickstart`_ @@ -436,6 +438,49 @@ e.g., testing or proxy setups. Either pass the ``endpoint`` option to the constructor, or set the ``SEAM_ENDPOINT`` environment variable. +Serializing URL search params +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Seam API parses URL search params as complex types. +This SDK implements the `Seam URL search params serialization standard`_, +which defines how the Seam SDKs serialize objects to URL search params. +Use it directly when building requests to the Seam API by hand: + +.. code-block:: python + + from seam import serialize_url_search_params + + serialize_url_search_params( + { + "name": "Dax", + "age": 27, + "is_admin": True, + "tags": ["cars", "planes"], + } + ) + # => 'age=27&is_admin=true&name=Dax&tags=cars&tags=planes' + +Params are sorted by name, so equivalent input always produces the same query string. +Nested dicts are serialized to dot-path keys, e.g., ``{"a": {"b": 1}}`` becomes ``a.b=1``. +Params set to ``None`` are serialized to an empty value, e.g., ``a=``, +while params set to ``seam.UNDEFINED`` are removed. +A param that cannot be represented raises a ``seam.UnserializableParamError``. + +To merge serialized params into existing params, use ``update_url_search_params``: + +.. code-block:: python + + from seam import UrlSearchParams, update_url_search_params + + search_params = UrlSearchParams("?foo=bar") + + update_url_search_params(search_params, {"name": "Dax"}) + + str(search_params) + # => 'foo=bar&name=Dax' + +.. _Seam URL search params serialization standard: https://github.com/seamapi/url-search-params-serializer + Development and Testing ----------------------- diff --git a/seam/__init__.py b/seam/__init__.py index f93f29d1..f7c5fcf5 100644 --- a/seam/__init__.py +++ b/seam/__init__.py @@ -15,3 +15,10 @@ ) from .seam_webhook import SeamWebhook from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError +from .utils.url_search_params_serializer import ( + UNDEFINED, + UnserializableParamError, + UrlSearchParams, + serialize_url_search_params, + update_url_search_params, +) diff --git a/seam/utils/url_search_params_serializer.py b/seam/utils/url_search_params_serializer.py new file mode 100644 index 00000000..2c6e5588 --- /dev/null +++ b/seam/utils/url_search_params_serializer.py @@ -0,0 +1,460 @@ +"""Serializes Python objects to URL search params. + +This is a Python port of the `@seamapi/url-search-params-serializer +`_ reference +implementation, which defines the standard for how the Seam SDKs and other +Seam API consumers serialize objects to URL search params in HTTP GET requests. + +Output is byte-for-byte identical to the reference implementation: +values are encoded with the ``application/x-www-form-urlencoded`` serializer, +params are sorted by name, and numbers are formatted using the +ECMAScript ``Number::toString`` algorithm. + +Type mapping between the reference implementation and this port: + +- JavaScript ``undefined`` is :data:`UNDEFINED`, or simply an absent key. +- JavaScript ``null`` is ``None``. +- JavaScript ``string`` is ``str``. +- JavaScript ``boolean`` is ``bool``. +- JavaScript ``number`` is ``float`` or ``int``. +- JavaScript ``bigint`` is ``int``. + Python integers are arbitrary precision, so ``int`` covers both cases + and is always serialized in full without exponent notation. +- JavaScript ``Date`` and ``Temporal.Instant`` are + :class:`datetime.datetime`. + A naive ``datetime`` is interpreted as UTC. + Since ``Date`` has millisecond precision, microseconds are truncated. +- JavaScript ``Array`` is ``list`` or ``tuple``. + Unordered collections such as ``set`` are unsupported + because they would not serialize deterministically. +- A JavaScript plain object is any ``Mapping``, e.g., a ``dict``. +""" + +import datetime +import math +import string +from collections.abc import Mapping +from decimal import Decimal +from typing import Any, Iterator, List, Optional, Sequence, Tuple, Union +from urllib.parse import parse_qsl + +Params = Mapping[str, Any] + + +class UnserializableParamError(Exception): + """Exception raised when a param could not be serialized. + + :ivar name: Name of the param that could not be serialized + :vartype name: str + """ + + def __init__(self, name: str, message: str): + """ + :param name: Name of the param that could not be serialized + :type name: str + :param message: Description of why the param could not be serialized + :type message: str + """ + + super().__init__(f"Could not serialize parameter: '{name}' {message}") + self.name = name + + +class _Undefined: + """Type of the :data:`UNDEFINED` sentinel.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self): + return "UNDEFINED" + + def __bool__(self): + return False + + +UNDEFINED = _Undefined() +"""Sentinel for the absence of a value, equivalent to JavaScript ``undefined``. + +Params set to this sentinel are removed, whereas params set to ``None`` +are serialized to an empty value. Omitting the key entirely is equivalent. +""" + + +class UrlSearchParams: + """A mutable collection of URL search params. + + Implements the parts of the `URLSearchParams + `_ + interface needed to serialize params to a query string. + Unlike a ``dict``, a name may appear more than once, + which is how arrays are serialized. + """ + + def __init__( + self, + init: Optional[Union[str, Params, Sequence[Tuple[str, str]]]] = None, + ): + """ + :param init: A query string, a mapping of names to values, + or a sequence of name-value pairs + :type init: Optional[Union[str, Mapping[str, Any], Sequence[Tuple[str, str]]]] + """ + + self._pairs: List[Tuple[str, str]] = [] + + if init is None: + return + + if isinstance(init, str): + query = init[1:] if init.startswith("?") else init + self._pairs = list(parse_qsl(query, keep_blank_values=True)) + return + + items = init.items() if isinstance(init, Mapping) else init + self._pairs = [(str(name), str(value)) for name, value in items] + + def append(self, name: str, value: str) -> None: + """Appends a name-value pair, keeping any existing pairs with this name. + + :param name: Name of the param + :type name: str + :param value: Value of the param + :type value: str + """ + + self._pairs.append((name, value)) + + def set(self, name: str, value: str) -> None: + """Sets the value associated with a name. + + Replaces the first pair with this name and removes any others. + Appends a new pair if no pair with this name exists. + + :param name: Name of the param + :type name: str + :param value: Value of the param + :type value: str + """ + + if not self.has(name): + self.append(name, value) + return + + pairs: List[Tuple[str, str]] = [] + is_set = False + + for pair in self._pairs: + if pair[0] != name: + pairs.append(pair) + elif not is_set: + pairs.append((name, value)) + is_set = True + + self._pairs = pairs + + def get(self, name: str) -> Optional[str]: + """Returns the value of the first pair with this name. + + :param name: Name of the param + :type name: str + + :returns: The value, or ``None`` if no pair with this name exists + """ + + for existing_name, value in self._pairs: + if existing_name == name: + return value + + return None + + def get_all(self, name: str) -> List[str]: + """Returns the values of all pairs with this name, in insertion order. + + :param name: Name of the param + :type name: str + + :returns: The values""" + + return [value for existing_name, value in self._pairs if existing_name == name] + + def has(self, name: str) -> bool: + """Returns whether a pair with this name exists. + + :param name: Name of the param + :type name: str + + :returns: Whether a pair with this name exists""" + + return any(existing_name == name for existing_name, _ in self._pairs) + + def delete(self, name: str) -> None: + """Removes all pairs with this name. + + :param name: Name of the param + :type name: str + """ + + self._pairs = [pair for pair in self._pairs if pair[0] != name] + + def sort(self) -> None: + """Sorts all pairs by name. + + Sorting is stable, so the relative order of pairs + with the same name is preserved. + Names are compared by UTF-16 code units to match the + `URLSearchParams.sort() + `_ + specification. + """ + + self._pairs.sort(key=lambda pair: pair[0].encode("utf-16-be")) + + def to_string(self) -> str: + """Serializes all pairs to a query string. + + :returns: The query string, without a leading ``?``""" + + return "&".join( + f"{_encode_form_component(name)}={_encode_form_component(value)}" + for name, value in self._pairs + ) + + def __str__(self) -> str: + return self.to_string() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.to_string()!r})" + + def __len__(self) -> int: + return len(self._pairs) + + def __iter__(self) -> Iterator[Tuple[str, str]]: + return iter(self._pairs) + + +def serialize_url_search_params(params: Params) -> str: + """Serializes params to a URL search param query string. + + :param params: The params to serialize + :type params: Mapping[str, Any] + + :returns: The query string, without a leading ``?`` + + :raises UnserializableParamError: If any param could not be serialized + """ + + search_params = UrlSearchParams() + update_url_search_params(search_params, params) + + return search_params.to_string() + + +def update_url_search_params(search_params: UrlSearchParams, params: Params) -> None: + """Updates existing URL search params with serialized params. + + Existing params are preserved unless overwritten by a serialized param. + All params are sorted by name. + + :param search_params: The URL search params to update + :type search_params: UrlSearchParams + :param params: The params to serialize + :type params: Mapping[str, Any] + + :raises UnserializableParamError: If any param could not be serialized + """ + + _nested_update_url_search_params(search_params, params, []) + search_params.sort() + + +def _nested_update_url_search_params( + search_params: UrlSearchParams, params: Params, path: List[str] +) -> None: + for key, value in params.items(): + if not isinstance(key, str): + raise UnserializableParamError( + repr(key), + f"is a {type(key).__name__} which is unsupported as a parameter name", + ) + + if "." in key: + raise UnserializableParamError( + key, + 'contains one or more dots "." in its name which is unsupported', + ) + + current_path = [*path, key] + + if isinstance(value, Mapping): + _nested_update_url_search_params(search_params, value, current_path) + continue + + name = ".".join(current_path) + + if _is_undefined(value): + continue + + if isinstance(value, str) and len(value) == 0: + continue + + if isinstance(value, (list, tuple)): + _update_url_search_params_from_array(search_params, name, value) + continue + + search_params.set(name, _serialize(name, value)) + + +def _update_url_search_params_from_array( + search_params: UrlSearchParams, name: str, values: Sequence[Any] +) -> None: + if len(values) == 0: + search_params.set(name, "") + return + + if len(values) == 1 and _is_empty_string(values[0]): + raise UnserializableParamError( + name, + "is a single element array containing the empty string which is unsupported", + ) + + if any(_is_empty_string(value) for value in values): + raise UnserializableParamError( + name, + "is an array containing the empty string which is unsupported", + ) + + if any(value is None or _is_undefined(value) for value in values): + raise UnserializableParamError( + name, + "is an array containing null or undefined values which is unsupported", + ) + + for value in values: + search_params.append(name, _serialize(name, value)) + + +def _serialize(name: str, value: Any) -> str: + if value is None: + return "" + + if isinstance(value, str): + return value + + if isinstance(value, bool): + return "true" if value else "false" + + if isinstance(value, int): + return str(value) + + if isinstance(value, float): + return _format_number(name, value) + + if isinstance(value, datetime.datetime): + return _format_datetime(value) + + raise UnserializableParamError(name, f"is a {type(value).__name__}") + + +def _is_empty_string(value: Any) -> bool: + return isinstance(value, str) and len(value) == 0 + + +def _is_undefined(value: Any) -> bool: + return isinstance(value, _Undefined) + + +def _format_datetime(value: datetime.datetime) -> str: + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + + utc_value = value.astimezone(datetime.timezone.utc) + milliseconds = utc_value.microsecond // 1000 + + return ( + f"{utc_value.year:04d}-{utc_value.month:02d}-{utc_value.day:02d}" + f"T{utc_value.hour:02d}:{utc_value.minute:02d}:{utc_value.second:02d}" + f".{milliseconds:03d}Z" + ) + + +def _format_number(name: str, value: float) -> str: + if math.isnan(value): + raise UnserializableParamError(name, "is NaN") + + if math.isinf(value): + raise UnserializableParamError( + name, "is Infinity" if value > 0 else "is -Infinity" + ) + + if value == 0: + return "0" + + sign = "-" if value < 0 else "" + _, digit_tuple, exponent = Decimal(repr(abs(value))).as_tuple() + + # The shortest digit string that round-trips, and the position of the + # decimal point relative to it, as required by the ECMAScript + # Number::toString algorithm. + digits = "".join(str(digit) for digit in digit_tuple) + point = int(exponent) + len(digits) + digits = digits.rstrip("0") + + return sign + _format_digits(digits, point) + + +def _format_digits(digits: str, point: int) -> str: + """Formats digits and a decimal point position per ECMAScript Number::toString. + + :param digits: Significant digits, without trailing zeros + :type digits: str + :param point: Position of the decimal point relative to the digits + :type point: int + + :returns: The formatted number""" + + count = len(digits) + + if count <= point <= 21: + return digits + "0" * (point - count) + + if 0 < point <= 21: + return f"{digits[:point]}.{digits[point:]}" + + if -6 < point <= 0: + return f"0.{'0' * -point}{digits}" + + exponent = point - 1 + exponent_sign = "+" if exponent >= 0 else "-" + mantissa = digits if count == 1 else f"{digits[0]}.{digits[1:]}" + + return f"{mantissa}e{exponent_sign}{abs(exponent)}" + + +_FORM_SAFE_CHARACTERS = frozenset(f"{string.ascii_letters}{string.digits}*-._") + + +def _encode_form_component(value: str) -> str: + """Percent-encodes a string using the ``application/x-www-form-urlencoded`` serializer. + + :param value: The string to encode + :type value: str + + :returns: The encoded string""" + + encoded = [] + + for byte in value.encode("utf-8"): + character = chr(byte) + if character in _FORM_SAFE_CHARACTERS: + encoded.append(character) + elif character == " ": + encoded.append("+") + else: + encoded.append(f"%{byte:02X}") + + return "".join(encoded) diff --git a/test/url_search_params_serializer_test.py b/test/url_search_params_serializer_test.py new file mode 100644 index 00000000..08459ad8 --- /dev/null +++ b/test/url_search_params_serializer_test.py @@ -0,0 +1,448 @@ +from collections import OrderedDict +from datetime import date, datetime, timedelta, timezone + +import pytest + +from seam.utils.url_search_params_serializer import ( + UNDEFINED, + UnserializableParamError, + UrlSearchParams, + serialize_url_search_params, + update_url_search_params, +) + + +def test_serializes_empty_object(): + assert serialize_url_search_params({}) == "" + + +def test_serializes_string(): + assert serialize_url_search_params({"foo": "d"}) == "foo=d" + assert serialize_url_search_params({"foo": "null"}) == "foo=null" + assert serialize_url_search_params({"foo": "None"}) == "foo=None" + assert serialize_url_search_params({"foo": "undefined"}) == "foo=undefined" + assert serialize_url_search_params({"foo": "0"}) == "foo=0" + + +def test_serializes_the_empty_string_to_undefined(): + assert serialize_url_search_params({"foo": ""}) == "" + assert serialize_url_search_params({"foo": "d", "bar": ""}) == "foo=d" + + +def test_serializes_int(): + assert serialize_url_search_params({"foo": 1}) == "foo=1" + assert serialize_url_search_params({"foo": 0}) == "foo=0" + assert serialize_url_search_params({"foo": -42}) == "foo=-42" + + +def test_serializes_arbitrary_precision_int(): + assert ( + serialize_url_search_params({"foo": 9007199254740993}) == "foo=9007199254740993" + ) + assert ( + serialize_url_search_params({"foo": 123456789012345678901234567890}) + == "foo=123456789012345678901234567890" + ) + + +def test_serializes_float(): + assert serialize_url_search_params({"foo": 23.8}) == "foo=23.8" + assert serialize_url_search_params({"foo": -23.8}) == "foo=-23.8" + assert serialize_url_search_params({"foo": 0.30000000000000004}) == ( + "foo=0.30000000000000004" + ) + + +def test_serializes_float_using_the_ecmascript_number_format(): + # A float is serialized exactly as JavaScript would serialize the number, + # which is not always the same as the Python repr. + assert serialize_url_search_params({"foo": 1.0}) == "foo=1" + assert serialize_url_search_params({"foo": -0.0}) == "foo=0" + assert serialize_url_search_params({"foo": 100.0}) == "foo=100" + assert serialize_url_search_params({"foo": 1e16}) == "foo=10000000000000000" + assert serialize_url_search_params({"foo": 1e20}) == "foo=100000000000000000000" + assert serialize_url_search_params({"foo": 1e21}) == "foo=1e%2B21" + assert serialize_url_search_params({"foo": 0.0001}) == "foo=0.0001" + assert serialize_url_search_params({"foo": 1e-6}) == "foo=0.000001" + assert serialize_url_search_params({"foo": 1e-7}) == "foo=1e-7" + assert serialize_url_search_params({"foo": 5e-324}) == "foo=5e-324" + assert serialize_url_search_params({"foo": 1.7976931348623157e308}) == ( + "foo=1.7976931348623157e%2B308" + ) + + +def test_serializes_bool(): + assert serialize_url_search_params({"foo": True}) == "foo=true" + assert serialize_url_search_params({"foo": False}) == "foo=false" + assert serialize_url_search_params({"foo": True, "bar": False}) == ( + "bar=false&foo=true" + ) + + +def test_removes_undefined_params(): + assert serialize_url_search_params({"bar": UNDEFINED}) == "" + assert serialize_url_search_params({"foo": 1, "bar": UNDEFINED}) == "foo=1" + + +def test_serializes_none_params(): + assert serialize_url_search_params({"bar": None}) == "bar=" + assert serialize_url_search_params({"foo": 1, "bar": None}) == "bar=&foo=1" + + +def test_serializes_empty_array_params(): + assert serialize_url_search_params({"bar": []}) == "bar=" + assert serialize_url_search_params({"foo": 1, "bar": []}) == "bar=&foo=1" + assert serialize_url_search_params({"bar": ()}) == "bar=" + + +def test_serializes_array_params_with_one_value(): + assert serialize_url_search_params({"bar": ["a"]}) == "bar=a" + assert serialize_url_search_params({"foo": 1, "bar": ["a"]}) == "bar=a&foo=1" + + +def test_serializes_array_params_with_many_values(): + assert serialize_url_search_params({"foo": 1, "bar": ["a", "2"]}) == ( + "bar=a&bar=2&foo=1" + ) + assert serialize_url_search_params( + {"foo": 1, "bar": ["null", "2", "undefined"]} + ) == ("bar=null&bar=2&bar=undefined&foo=1") + + +def test_serializes_tuple_params(): + assert serialize_url_search_params({"bar": ("a", "2")}) == "bar=a&bar=2" + + +def test_serializes_array_params_with_mixed_values(): + assert serialize_url_search_params( + {"bar": [1, "a", True, datetime(1970, 1, 1, tzinfo=timezone.utc)]} + ) == ("bar=1&bar=a&bar=true&bar=1970-01-01T00%3A00%3A00.000Z") + + +def test_serializes_datetime(): + assert serialize_url_search_params( + {"foo": 1, "now": datetime(2025, 2, 24, 18, 44, 39, tzinfo=timezone.utc)} + ) == ("foo=1&now=2025-02-24T18%3A44%3A39.000Z") + + +def test_serializes_datetime_with_milliseconds(): + assert serialize_url_search_params( + { + "now": datetime( + 2025, 2, 24, 18, 44, 39, microsecond=123000, tzinfo=timezone.utc + ) + } + ) == ("now=2025-02-24T18%3A44%3A39.123Z") + + +def test_truncates_datetime_microseconds(): + assert serialize_url_search_params( + { + "now": datetime( + 2025, 2, 24, 18, 44, 39, microsecond=123999, tzinfo=timezone.utc + ) + } + ) == ("now=2025-02-24T18%3A44%3A39.123Z") + + +def test_serializes_datetime_as_utc(): + assert serialize_url_search_params( + {"now": datetime(2025, 2, 24, 13, 44, 39, tzinfo=timezone(timedelta(hours=-5)))} + ) == ("now=2025-02-24T18%3A44%3A39.000Z") + + +def test_serializes_naive_datetime_as_utc(): + assert serialize_url_search_params({"now": datetime(2025, 2, 24, 18, 44, 39)}) == ( + "now=2025-02-24T18%3A44%3A39.000Z" + ) + + +def test_serializes_datetime_before_the_epoch(): + assert serialize_url_search_params( + {"then": datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc)} + ) == ("then=1969-12-31T23%3A59%3A59.000Z") + + +def test_serializes_dicts(): + assert serialize_url_search_params({"foo": 1, "bar": {"baz": "a"}}) == ( + "bar.baz=a&foo=1" + ) + + assert serialize_url_search_params({"foo": 1, "bar": {"baz": {"x": {"z": 1}}}}) == ( + "bar.baz.x.z=1&foo=1" + ) + + assert serialize_url_search_params( + {"foo": 1, "bar": {"baz": {"x": {"z": None}}}} + ) == ("bar.baz.x.z=&foo=1") + + assert serialize_url_search_params({"foo": 1, "bar": {"baz": [1, "a"]}}) == ( + "bar.baz=1&bar.baz=a&foo=1" + ) + + assert serialize_url_search_params({"foo": {}, "bar": 2}) == "bar=2" + + assert serialize_url_search_params({"foo": {"x": {}}, "bar": 2}) == "bar=2" + + assert serialize_url_search_params( + {"foo": {}, "bar": {"baz": {"x": {"z": None, "t": {}}, "q": {}}}} + ) == ("bar.baz.x.z=") + + +def test_serializes_dict_subclasses(): + assert serialize_url_search_params( + {"foo": OrderedDict([("bar", 1), ("baz", 2)])} + ) == ("foo.bar=1&foo.baz=2") + + +def test_sorts_params_by_name(): + assert serialize_url_search_params({"b": 1, "a": 2, "c": 3}) == "a=2&b=1&c=3" + assert serialize_url_search_params({"b": 1, "A": 2, "a": 3, "B": 4}) == ( + "A=2&B=4&a=3&b=1" + ) + assert serialize_url_search_params({"a10": 1, "a2": 2, "a1": 3}) == ( + "a1=3&a10=1&a2=2" + ) + assert serialize_url_search_params({"zz": 1, "a": {"z": 2, "b": 3}}) == ( + "a.b=3&a.z=2&zz=1" + ) + assert serialize_url_search_params({"ab": 1, "a": {"b": 2}}) == "a.b=2&ab=1" + + +def test_sorts_params_by_utf_16_code_unit(): + assert serialize_url_search_params({"￿": 1, "\U0001f600": 2}) == ( + "%F0%9F%98%80=2&%EF%BF%BF=1" + ) + + +def test_sorting_preserves_array_order(): + assert serialize_url_search_params({"b": ["3", "1", "2"], "a": 1}) == ( + "a=1&b=3&b=1&b=2" + ) + + +def test_encodes_params_as_form_urlencoded(): + assert serialize_url_search_params({"foo": "a b"}) == "foo=a+b" + assert serialize_url_search_params({"foo": "a+b"}) == "foo=a%2Bb" + assert serialize_url_search_params({"foo": "a~b"}) == "foo=a%7Eb" + assert serialize_url_search_params({"foo": "a*b"}) == "foo=a*b" + assert serialize_url_search_params({"foo": "abcXYZ019*-._"}) == "foo=abcXYZ019*-._" + assert serialize_url_search_params({"foo": "a&b=c?d#e/f"}) == ( + "foo=a%26b%3Dc%3Fd%23e%2Ff" + ) + assert serialize_url_search_params({"foo": "100%"}) == "foo=100%25" + assert serialize_url_search_params({"foo": "a\nb"}) == "foo=a%0Ab" + + +def test_encodes_unicode_params(): + assert serialize_url_search_params({"foo": "héllo wörld"}) == ( + "foo=h%C3%A9llo+w%C3%B6rld" + ) + assert serialize_url_search_params({"foo": "日本語"}) == ( + "foo=%E6%97%A5%E6%9C%AC%E8%AA%9E" + ) + assert serialize_url_search_params({"🔒": "a"}) == "%F0%9F%94%92=a" + assert serialize_url_search_params({"a b": 1}) == "a+b=1" + + +def test_cannot_serialize_keys_containing_a_dot(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo.bar": 1}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": {"bar.baz": 1}}) + + +def test_cannot_serialize_non_string_keys(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({1: "a"}) + + +def test_cannot_serialize_functions(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": lambda: None}) + + +def test_cannot_serialize_number_pointers(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": float("inf")}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": float("-inf")}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": float("nan")}) + + +def test_cannot_serialize_arbitrary_objects(): + class Device: + def __init__(self): + self.device_id = "a" + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": Device()}) + + +def test_cannot_serialize_date(): + # A date is not an instant, so it has no unambiguous serialization. + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": date(2025, 2, 24)}) + + +def test_cannot_serialize_sets(): + # A set would not serialize deterministically. + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": {"a", "b"}}) + + +def test_cannot_serialize_array_params_with_unserializable_values(): + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": [""]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", None]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", UNDEFINED]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", ["s"]]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", []]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", [""]]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", {}]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", {"x": 2}]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"bar": ["a", lambda: None]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": 1, "bar": ["", "a", ""]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": 1, "bar": ["", "a", "2"]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": 1, "bar": ["", "", ""]}) + + with pytest.raises(UnserializableParamError): + serialize_url_search_params({"foo": [1, float("nan")]}) + + +def test_unserializable_param_error_message(): + with pytest.raises(UnserializableParamError) as error: + serialize_url_search_params({"foo": {"bar.baz": 1}}) + + assert str(error.value) == ( + "Could not serialize parameter: 'bar.baz' contains one or more dots" + ' "." in its name which is unsupported' + ) + assert error.value.name == "bar.baz" + + +def test_unserializable_param_error_message_uses_the_full_path(): + with pytest.raises(UnserializableParamError) as error: + serialize_url_search_params({"foo": {"bar": float("nan")}}) + + assert str(error.value) == "Could not serialize parameter: 'foo.bar' is NaN" + + +def test_update_url_search_params(): + search_params = UrlSearchParams() + update_url_search_params(search_params, {"foo": "d", "bar": 2}) + + assert search_params.to_string() == "bar=2&foo=d" + + +def test_update_url_search_params_preserves_existing_params(): + search_params = UrlSearchParams([("foo", "bar")]) + update_url_search_params( + search_params, + {"name": "Dax", "age": 27, "isAdmin": True, "tags": ["cars", "planes"]}, + ) + + assert search_params.to_string() == ( + "age=27&foo=bar&isAdmin=true&name=Dax&tags=cars&tags=planes" + ) + + +def test_update_url_search_params_overwrites_existing_params(): + search_params = UrlSearchParams([("foo", "a"), ("bar", "x"), ("foo", "b")]) + update_url_search_params(search_params, {"foo": "new"}) + + assert search_params.to_string() == "bar=x&foo=new" + + +def test_update_url_search_params_appends_array_params(): + search_params = UrlSearchParams([("foo", "old")]) + update_url_search_params(search_params, {"foo": [1, 2]}) + + assert search_params.to_string() == "foo=old&foo=1&foo=2" + + +def test_update_url_search_params_keeps_existing_params_for_absent_values(): + for value in [UNDEFINED, "", {}]: + search_params = UrlSearchParams([("foo", "a")]) + update_url_search_params(search_params, {"foo": value}) + + assert search_params.to_string() == "foo=a" + + +def test_url_search_params_from_query_string(): + search_params = UrlSearchParams("?a=1&b=hello+world&c=%F0%9F%94%92&d") + + assert search_params.get("a") == "1" + assert search_params.get("b") == "hello world" + assert search_params.get("c") == "🔒" + assert search_params.get("d") == "" + assert search_params.to_string() == "a=1&b=hello+world&c=%F0%9F%94%92&d=" + + +def test_url_search_params_from_dict(): + assert UrlSearchParams({"a": "1", "b": "2"}).to_string() == "a=1&b=2" + + +def test_url_search_params_append_and_get(): + search_params = UrlSearchParams() + search_params.append("foo", "a") + search_params.append("foo", "b") + + assert search_params.get("foo") == "a" + assert search_params.get_all("foo") == ["a", "b"] + assert search_params.get("bar") is None + assert search_params.get_all("bar") == [] + assert len(search_params) == 2 + assert list(search_params) == [("foo", "a"), ("foo", "b")] + + +def test_url_search_params_set(): + search_params = UrlSearchParams([("foo", "a"), ("bar", "x"), ("foo", "b")]) + search_params.set("foo", "c") + + assert list(search_params) == [("foo", "c"), ("bar", "x")] + + search_params.set("baz", "y") + + assert search_params.get("baz") == "y" + + +def test_url_search_params_has_and_delete(): + search_params = UrlSearchParams([("foo", "a"), ("foo", "b")]) + + assert search_params.has("foo") + + search_params.delete("foo") + + assert not search_params.has("foo") + assert len(search_params) == 0 + + +def test_url_search_params_str(): + assert str(UrlSearchParams([("foo", "a b")])) == "foo=a+b" From ca39b4483b8ddd9eb004d61d287f388616b2136a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 17:18:35 +0000 Subject: [PATCH 2/3] 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 Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --- README.rst | 46 ++++++++- seam/__init__.py | 2 +- seam/client.py | 7 ++ seam/null.py | 95 ++++++++++++++++++ seam/utils/url_search_params_serializer.py | 43 ++------ test/null_test.py | 110 +++++++++++++++++++++ test/url_search_params_serializer_test.py | 30 +++--- 7 files changed, 284 insertions(+), 49 deletions(-) create mode 100644 seam/null.py create mode 100644 test/null_test.py diff --git a/README.rst b/README.rst index 1678fdaa..c8322a87 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,8 @@ Contents * `Webhooks`_ + * `Omitted params and null params`_ + * `Advanced Usage`_ * `Setting the endpoint`_ @@ -427,6 +429,45 @@ see the `Svix docs for more examples in specific frameworks bool: + """Returns whether a value is the :data:`NULL` sentinel. + + :param value: The value to check + :type value: Any + + :returns: Whether the value is the ``NULL`` sentinel""" + + return isinstance(value, Null) + + +def replace_null(value: Any) -> Any: + """Recursively replaces the :data:`NULL` sentinel with ``None``. + + Returns a copy, so the given value is never modified. + Use this to prepare a request payload for JSON serialization, + where ``None`` is serialized to null. + + :param value: The value to convert + :type value: Any + + :returns: A copy of the value with every ``NULL`` sentinel replaced""" + + if is_null(value): + return None + + if isinstance(value, Mapping): + return {key: replace_null(item) for key, item in value.items()} + + if isinstance(value, list): + return [replace_null(item) for item in value] + + if isinstance(value, tuple): + return tuple(replace_null(item) for item in value) + + return value diff --git a/seam/utils/url_search_params_serializer.py b/seam/utils/url_search_params_serializer.py index 2c6e5588..0bdfcdeb 100644 --- a/seam/utils/url_search_params_serializer.py +++ b/seam/utils/url_search_params_serializer.py @@ -12,8 +12,10 @@ Type mapping between the reference implementation and this port: -- JavaScript ``undefined`` is :data:`UNDEFINED`, or simply an absent key. -- JavaScript ``null`` is ``None``. +- JavaScript ``undefined`` is ``None``, or simply an absent key. +- JavaScript ``null`` is :data:`seam.NULL `. + Python has a single absence value, so ``None`` means the safe option of + omitting the param and sending null is always explicit. - JavaScript ``string`` is ``str``. - JavaScript ``boolean`` is ``bool``. - JavaScript ``number`` is ``float`` or ``int``. @@ -38,6 +40,8 @@ from typing import Any, Iterator, List, Optional, Sequence, Tuple, Union from urllib.parse import parse_qsl +from ..null import is_null + Params = Mapping[str, Any] @@ -60,31 +64,6 @@ def __init__(self, name: str, message: str): self.name = name -class _Undefined: - """Type of the :data:`UNDEFINED` sentinel.""" - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __repr__(self): - return "UNDEFINED" - - def __bool__(self): - return False - - -UNDEFINED = _Undefined() -"""Sentinel for the absence of a value, equivalent to JavaScript ``undefined``. - -Params set to this sentinel are removed, whereas params set to ``None`` -are serialized to an empty value. Omitting the key entirely is equivalent. -""" - - class UrlSearchParams: """A mutable collection of URL search params. @@ -296,7 +275,7 @@ def _nested_update_url_search_params( name = ".".join(current_path) - if _is_undefined(value): + if value is None: continue if isinstance(value, str) and len(value) == 0: @@ -328,7 +307,7 @@ def _update_url_search_params_from_array( "is an array containing the empty string which is unsupported", ) - if any(value is None or _is_undefined(value) for value in values): + if any(value is None or is_null(value) for value in values): raise UnserializableParamError( name, "is an array containing null or undefined values which is unsupported", @@ -339,7 +318,7 @@ def _update_url_search_params_from_array( def _serialize(name: str, value: Any) -> str: - if value is None: + if is_null(value): return "" if isinstance(value, str): @@ -364,10 +343,6 @@ def _is_empty_string(value: Any) -> bool: return isinstance(value, str) and len(value) == 0 -def _is_undefined(value: Any) -> bool: - return isinstance(value, _Undefined) - - def _format_datetime(value: datetime.datetime) -> str: if value.tzinfo is None: value = value.replace(tzinfo=datetime.timezone.utc) diff --git a/test/null_test.py b/test/null_test.py new file mode 100644 index 00000000..36b1a452 --- /dev/null +++ b/test/null_test.py @@ -0,0 +1,110 @@ +from collections import OrderedDict + +import niquests +import pytest + +from seam.client import SeamHttpClient +from seam.null import NULL, Null, is_null, replace_null + + +def test_null_is_a_singleton(): + assert Null() is NULL + assert is_null(NULL) + assert is_null(Null()) + + +def test_null_is_not_none(): + assert NULL is not None + assert not is_null(None) + assert not is_null("") + assert not is_null(0) + + +def test_null_is_falsy(): + assert not NULL + + +def test_null_repr(): + assert repr(NULL) == "NULL" + + +def test_replace_null(): + assert replace_null(NULL) is None + assert replace_null(None) is None + assert replace_null("a") == "a" + assert replace_null(0) == 0 + assert replace_null(False) is False + + +def test_replace_null_in_dict(): + assert replace_null({"a": NULL, "b": 1, "c": None}) == { + "a": None, + "b": 1, + "c": None, + } + + +def test_replace_null_in_nested_dict(): + assert replace_null({"a": {"b": {"c": NULL}}}) == {"a": {"b": {"c": None}}} + + +def test_replace_null_in_lists_and_tuples(): + assert replace_null(["a", NULL]) == ["a", None] + assert replace_null(("a", NULL)) == ("a", None) + assert replace_null({"a": [{"b": NULL}]}) == {"a": [{"b": None}]} + + +def test_replace_null_does_not_modify_the_given_value(): + params = {"a": NULL, "b": [NULL]} + replace_null(params) + + assert params == {"a": NULL, "b": [NULL]} + + +def test_replace_null_normalizes_mappings_to_dicts(): + result = replace_null(OrderedDict([("a", NULL)])) + + assert result == {"a": None} + + +class StubResponse: + status_code = 200 + headers = {"content-type": "application/json"} + + def json(self): + return {} + + +@pytest.fixture(name="sent_payloads") +def sent_payloads_fixture(monkeypatch): + payloads = [] + + # pylint: disable=unused-argument + def request(self, method, url, *args, **kwargs): + payloads.append(kwargs.get("json")) + return StubResponse() + + monkeypatch.setattr(niquests.Session, "request", request) + + return payloads + + +def test_client_sends_null_params_as_json_null(sent_payloads): + client = SeamHttpClient(base_url="https://example.com", auth_headers={}) + client.post("/devices/update", json={"device_id": "a", "name": NULL}) + + assert sent_payloads == [{"device_id": "a", "name": None}] + + +def test_client_sends_nested_null_params_as_json_null(sent_payloads): + client = SeamHttpClient(base_url="https://example.com", auth_headers={}) + client.post("/spaces/update", json={"customer_data": {"check_in": NULL}}) + + assert sent_payloads == [{"customer_data": {"check_in": None}}] + + +def test_client_passes_through_payloads_without_null_params(sent_payloads): + client = SeamHttpClient(base_url="https://example.com", auth_headers={}) + client.post("/devices/update", json={"device_id": "a", "name": "Front Door"}) + + assert sent_payloads == [{"device_id": "a", "name": "Front Door"}] diff --git a/test/url_search_params_serializer_test.py b/test/url_search_params_serializer_test.py index 08459ad8..6ce3a8e2 100644 --- a/test/url_search_params_serializer_test.py +++ b/test/url_search_params_serializer_test.py @@ -3,8 +3,8 @@ import pytest +from seam.null import NULL from seam.utils.url_search_params_serializer import ( - UNDEFINED, UnserializableParamError, UrlSearchParams, serialize_url_search_params, @@ -24,7 +24,8 @@ def test_serializes_string(): assert serialize_url_search_params({"foo": "0"}) == "foo=0" -def test_serializes_the_empty_string_to_undefined(): +def test_removes_the_empty_string(): + # Serializing the empty string would conflict with NULL. assert serialize_url_search_params({"foo": ""}) == "" assert serialize_url_search_params({"foo": "d", "bar": ""}) == "foo=d" @@ -79,14 +80,19 @@ def test_serializes_bool(): ) -def test_removes_undefined_params(): - assert serialize_url_search_params({"bar": UNDEFINED}) == "" - assert serialize_url_search_params({"foo": 1, "bar": UNDEFINED}) == "foo=1" +def test_removes_none_params(): + assert serialize_url_search_params({"bar": None}) == "" + assert serialize_url_search_params({"foo": 1, "bar": None}) == "foo=1" -def test_serializes_none_params(): - assert serialize_url_search_params({"bar": None}) == "bar=" - assert serialize_url_search_params({"foo": 1, "bar": None}) == "bar=&foo=1" +def test_serializes_null_params(): + assert serialize_url_search_params({"bar": NULL}) == "bar=" + assert serialize_url_search_params({"foo": 1, "bar": NULL}) == "bar=&foo=1" + + +def test_removes_none_params_at_any_depth(): + assert serialize_url_search_params({"foo": {"bar": None, "baz": 1}}) == "foo.baz=1" + assert serialize_url_search_params({"foo": {"bar": None}}) == "" def test_serializes_empty_array_params(): @@ -173,7 +179,7 @@ def test_serializes_dicts(): ) assert serialize_url_search_params( - {"foo": 1, "bar": {"baz": {"x": {"z": None}}}} + {"foo": 1, "bar": {"baz": {"x": {"z": NULL}}}} ) == ("bar.baz.x.z=&foo=1") assert serialize_url_search_params({"foo": 1, "bar": {"baz": [1, "a"]}}) == ( @@ -185,7 +191,7 @@ def test_serializes_dicts(): assert serialize_url_search_params({"foo": {"x": {}}, "bar": 2}) == "bar=2" assert serialize_url_search_params( - {"foo": {}, "bar": {"baz": {"x": {"z": None, "t": {}}, "q": {}}}} + {"foo": {}, "bar": {"baz": {"x": {"z": NULL, "t": {}}, "q": {}}}} ) == ("bar.baz.x.z=") @@ -303,7 +309,7 @@ def test_cannot_serialize_array_params_with_unserializable_values(): serialize_url_search_params({"bar": ["a", None]}) with pytest.raises(UnserializableParamError): - serialize_url_search_params({"bar": ["a", UNDEFINED]}) + serialize_url_search_params({"bar": ["a", NULL]}) with pytest.raises(UnserializableParamError): serialize_url_search_params({"bar": ["a", ["s"]]}) @@ -388,7 +394,7 @@ def test_update_url_search_params_appends_array_params(): def test_update_url_search_params_keeps_existing_params_for_absent_values(): - for value in [UNDEFINED, "", {}]: + for value in [None, "", {}]: search_params = UrlSearchParams([("foo", "a")]) update_url_search_params(search_params, {"foo": value}) From 0ddce5dd660fdc1e76745e14648f6d1e898f6162 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 22:53:41 +0000 Subject: [PATCH 3/3] feat: type nullable request params precisely Blueprint now reports isNullable for request parameters, so codegen can distinguish the params the Seam API documents as nullable from the rest. Type a nullable param as Union[T, Null] so it accepts the NULL sentinel, and leave every other param as it was. This makes NULL checkable. Previously NULL had to be typed as Any to be passed anywhere, which meant a type checker could not report sending null to a param that does not accept it. NULL is now typed as Null, so passing it to a non-nullable param such as devices.update(is_managed=...) is an error while access_grants.list(access_grant_key=NULL) is accepted. Reading isNullable requires blueprint 1.4.0 or later, which turns an untyped property from a warning into an error. The pinned types release leaves submit_args untyped for /seam/connect_webview/v1/submit, so generation fails against it; bump types to the next release, which defines that type and adds the between parameter to events.list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PpqwDhZmyikGbjmCPqFW2A --- README.rst | 4 +- codegen/layouts/partials/method-signature.hbs | 2 +- codegen/layouts/route.hbs | 1 + codegen/lib/class-model.ts | 1 + codegen/lib/handlebars-helpers.ts | 5 ++ codegen/lib/layouts/route.ts | 2 + codegen/lib/routes.ts | 1 + package-lock.json | 18 +++--- package.json | 4 +- seam/null.py | 6 +- seam/routes/access_codes.py | 5 +- seam/routes/access_codes_simulate.py | 1 + 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/access_methods_unmanaged.py | 1 + seam/routes/acs.py | 1 + seam/routes/acs_access_groups.py | 1 + seam/routes/acs_credentials.py | 5 +- seam/routes/acs_encoders.py | 5 +- seam/routes/acs_encoders_simulate.py | 1 + seam/routes/acs_entrances.py | 9 +-- seam/routes/acs_systems.py | 1 + seam/routes/acs_users.py | 9 +-- seam/routes/action_attempts.py | 5 +- seam/routes/client_sessions.py | 1 + seam/routes/connect_webviews.py | 5 +- seam/routes/connected_accounts.py | 5 +- seam/routes/connected_accounts_simulate.py | 1 + seam/routes/customers.py | 1 + seam/routes/devices.py | 13 ++-- seam/routes/devices_simulate.py | 1 + seam/routes/devices_unmanaged.py | 9 +-- seam/routes/events.py | 5 +- seam/routes/instant_keys.py | 1 + seam/routes/locks.py | 9 +-- seam/routes/locks_simulate.py | 1 + seam/routes/noise_sensors.py | 9 +-- seam/routes/noise_sensors_noise_thresholds.py | 1 + seam/routes/noise_sensors_simulate.py | 1 + seam/routes/phones.py | 1 + seam/routes/phones_simulate.py | 1 + seam/routes/spaces.py | 5 +- seam/routes/thermostats.py | 61 ++++++++++--------- seam/routes/thermostats_daily_programs.py | 1 + seam/routes/thermostats_schedules.py | 9 +-- seam/routes/thermostats_simulate.py | 1 + seam/routes/user_identities.py | 37 +++++------ seam/routes/user_identities_unmanaged.py | 5 +- seam/routes/webhooks.py | 1 + seam/routes/workspaces.py | 5 +- 52 files changed, 186 insertions(+), 132 deletions(-) diff --git a/README.rst b/README.rst index c8322a87..7e878f87 100644 --- a/README.rst +++ b/README.rst @@ -444,7 +444,9 @@ Python has a single absence value, so this SDK maps the two cases as follows: Sending null is rarely intended and unsetting a value cannot be undone, so ``None`` means the safe option of omitting the param -and sending null is always explicit: +and sending null is always explicit. +Route methods accept ``NULL`` only for the params the Seam API documents as +nullable, so a type checker reports passing it to any other param as an error: .. code-block:: python 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 1793adfb..40526275 100644 --- a/codegen/layouts/route.hbs +++ b/codegen/layouts/route.hbs @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null {{#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 9937221a..571ed301 100644 --- a/codegen/lib/class-model.ts +++ b/codegen/lib/class-model.ts @@ -9,6 +9,7 @@ export interface ClassMethodParameter { deprecationMessage: string position?: number | undefined required?: boolean | undefined + isNullable?: boolean | undefined } export interface ClassMethod { diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index dd4abeda..d7d9adc4 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -56,3 +56,8 @@ export const pythonIdentifier = (name: string): string => export const isListType = (type: string): boolean => type.startsWith('List[') export const listItemType = (type: string): string => type.slice(5, -1) + +// A nullable param accepts the NULL sentinel, which is sent as null. +// A param set to None is omitted from the request instead. +export const nullableType = (type: string, isNullable: boolean): string => + isNullable ? `Union[${type}, Null]` : type diff --git a/codegen/lib/layouts/route.ts b/codegen/lib/layouts/route.ts index 8e57326c..8a640fe7 100644 --- a/codegen/lib/layouts/route.ts +++ b/codegen/lib/layouts/route.ts @@ -22,6 +22,7 @@ export interface MethodLayoutContext { isDeprecated: boolean deprecationMessage: string required: boolean + isNullable: boolean }> returnPath: string[] returnType: string @@ -67,6 +68,7 @@ export const getMethodLayoutContext = ( isDeprecated: parameter.isDeprecated, deprecationMessage: parameter.deprecationMessage, required: parameter.required ?? false, + isNullable: parameter.isNullable ?? false, })), returnPath: method.returnPath, returnType: method.returnResource, diff --git a/codegen/lib/routes.ts b/codegen/lib/routes.ts index 0cd065d2..37f44546 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -101,6 +101,7 @@ export const routes = ( deprecationMessage: parameter.deprecationMessage, position: parameter.name === idParameterName ? 0 : undefined, required: parameter.isRequired, + isNullable: parameter.isNullable, })), ...resolveResponse(response), }) diff --git a/package-lock.json b/package-lock.json index 10183c3d..71fadd07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,10 @@ "": { "name": "@seamapi/python", "devDependencies": { - "@seamapi/blueprint": "^1.1.0", + "@seamapi/blueprint": "^1.4.0", "@seamapi/fake-seam-connect": "1.86.0", "@seamapi/smith": "^1.1.0", - "@seamapi/types": "1.983.0", + "@seamapi/types": "1.984.0", "change-case": "^5.4.4", "prettier": "^3.2.5" }, @@ -787,9 +787,9 @@ "license": "MIT" }, "node_modules/@seamapi/blueprint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.1.0.tgz", - "integrity": "sha512-wX1HZkA/IK9hDQ6Qdxw5Mo+Ysfh82p9IEXQJafakO9VMbszW6n1U02eEhZHVY3CfzN/duk6t9h1veX0zRlhWBQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@seamapi/blueprint/-/blueprint-1.5.0.tgz", + "integrity": "sha512-UhLlcfgxUbnxoi4GoL45Kwvz3d9kfu64enYmdGYHWDbya1lpvBF5E9NVP52OOR9OjSZpQFyIg9mUV1rjgjTyUA==", "dev": true, "license": "MIT", "dependencies": { @@ -798,7 +798,7 @@ }, "engines": { "node": ">=22.11.0", - "npm": ">=10.9.4" + "npm": ">=10.0.0" } }, "node_modules/@seamapi/fake-devicedb": { @@ -871,9 +871,9 @@ } }, "node_modules/@seamapi/types": { - "version": "1.983.0", - "resolved": "https://registry.npmjs.org/@seamapi/types/-/types-1.983.0.tgz", - "integrity": "sha512-SMkfn1SVC70x67mtRAvLMJtpFh/0zaStLatb6LA+kz9n/rV1gBS/UlH8SzBFg7iStE22f/VPjkdltHTIY1paoA==", + "version": "1.984.0", + "resolved": "https://registry.npmjs.org/@seamapi/types/-/types-1.984.0.tgz", + "integrity": "sha512-qHyux+VxfbQ5FqeOuC/uSmF6nbtBtOeeQsinkU5rdarrhi4L1/euCDDBBRfxxG+7bfsEA0BQqQmA3GU/v31j9A==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 1a816343..c30c7eb5 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ } }, "devDependencies": { - "@seamapi/blueprint": "^1.1.0", + "@seamapi/blueprint": "^1.4.0", "@seamapi/fake-seam-connect": "1.86.0", "@seamapi/smith": "^1.1.0", - "@seamapi/types": "1.983.0", + "@seamapi/types": "1.984.0", "change-case": "^5.4.4", "prettier": "^3.2.5" } diff --git a/seam/null.py b/seam/null.py index c732d63f..fe8bc4d8 100644 --- a/seam/null.py +++ b/seam/null.py @@ -31,7 +31,7 @@ def __bool__(self): return False -NULL: Any = Null() +NULL = Null() """Sentinel for a param explicitly set to null. Params set to this sentinel are sent as null, @@ -52,8 +52,8 @@ def __bool__(self): # Lists only the Access Grants which have no access_grant_key. seam.access_grants.list(access_grant_key=NULL) -This sentinel is typed as ``Any`` so that it may be passed -to any param without a type error. +Route methods accept this sentinel only for params the Seam API +documents as nullable, so passing it to any other param is a type error. """ diff --git a/seam/routes/access_codes.py b/seam/routes/access_codes.py index a194118e..6742056b 100644 --- a/seam/routes/access_codes.py +++ b/seam/routes/access_codes.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AccessCode from .access_codes_simulate import AbstractAccessCodesSimulate, AccessCodesSimulate from .access_codes_unmanaged import AbstractAccessCodesUnmanaged, AccessCodesUnmanaged @@ -195,7 +196,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]: @@ -654,7 +655,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_simulate.py b/seam/routes/access_codes_simulate.py index 048c13fb..f7793d55 100644 --- a/seam/routes/access_codes_simulate.py +++ b/seam/routes/access_codes_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import UnmanagedAccessCode diff --git a/seam/routes/access_codes_unmanaged.py b/seam/routes/access_codes_unmanaged.py index ac90e4ee..8c317908 100644 --- a/seam/routes/access_codes_unmanaged.py +++ b/seam/routes/access_codes_unmanaged.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import UnmanagedAccessCode @@ -66,7 +67,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]: @@ -206,7 +207,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 f6835ebd..23772c63 100644 --- a/seam/routes/access_grants.py +++ b/seam/routes/access_grants.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AccessGrant, Batch from .access_grants_unmanaged import ( AbstractAccessGrantsUnmanaged, @@ -26,10 +27,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, @@ -121,14 +122,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 @@ -183,8 +184,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. @@ -222,10 +223,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, @@ -377,14 +378,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 @@ -479,8 +480,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 43786955..d6c3779a 100644 --- a/seam/routes/access_grants_unmanaged.py +++ b/seam/routes/access_grants_unmanaged.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import UnmanagedAccessGrant @@ -22,7 +23,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]: @@ -92,7 +93,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 ffafd2d5..92157741 100644 --- a/seam/routes/access_methods.py +++ b/seam/routes/access_methods.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ActionAttempt, AccessMethod, Batch from .access_methods_unmanaged import ( AbstractAccessMethodsUnmanaged, @@ -110,7 +111,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. @@ -319,7 +320,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/access_methods_unmanaged.py b/seam/routes/access_methods_unmanaged.py index 44ad9c5f..3e34d97a 100644 --- a/seam/routes/access_methods_unmanaged.py +++ b/seam/routes/access_methods_unmanaged.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import UnmanagedAccessMethod diff --git a/seam/routes/acs.py b/seam/routes/acs.py index 8207cf12..b334da87 100644 --- a/seam/routes/acs.py +++ b/seam/routes/acs.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from .acs_access_groups import AbstractAcsAccessGroups, AcsAccessGroups from .acs_credentials import AbstractAcsCredentials, AcsCredentials from .acs_encoders import AbstractAcsEncoders, AcsEncoders diff --git a/seam/routes/acs_access_groups.py b/seam/routes/acs_access_groups.py index 11ce5959..1904a869 100644 --- a/seam/routes/acs_access_groups.py +++ b/seam/routes/acs_access_groups.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AcsAccessGroup, AcsEntrance, AcsUser diff --git a/seam/routes/acs_credentials.py b/seam/routes/acs_credentials.py index c655973e..205adb21 100644 --- a/seam/routes/acs_credentials.py +++ b/seam/routes/acs_credentials.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AcsCredential, AcsEntrance @@ -99,7 +100,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 `_. @@ -322,7 +323,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 e537a0f3..44ae3566 100644 --- a/seam/routes/acs_encoders.py +++ b/seam/routes/acs_encoders.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ActionAttempt, AcsEncoder from .acs_encoders_simulate import AbstractAcsEncodersSimulate, AcsEncodersSimulate from ..modules.action_attempts import resolve_action_attempt @@ -52,7 +53,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 `_. @@ -188,7 +189,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_encoders_simulate.py b/seam/routes/acs_encoders_simulate.py index e08aaf10..b913bd21 100644 --- a/seam/routes/acs_encoders_simulate.py +++ b/seam/routes/acs_encoders_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null class AbstractAcsEncodersSimulate(abc.ABC): diff --git a/seam/routes/acs_entrances.py b/seam/routes/acs_entrances.py index 104779c0..946b0cb5 100644 --- a/seam/routes/acs_entrances.py +++ b/seam/routes/acs_entrances.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AcsEntrance, AcsCredential, ActionAttempt from ..modules.action_attempts import resolve_action_attempt @@ -45,8 +46,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]: @@ -168,8 +169,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_systems.py b/seam/routes/acs_systems.py index b4ca7d3d..b5e9b97f 100644 --- a/seam/routes/acs_systems.py +++ b/seam/routes/acs_systems.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AcsSystem diff --git a/seam/routes/acs_users.py b/seam/routes/acs_users.py index 7f59c38e..7e311944 100644 --- a/seam/routes/acs_users.py +++ b/seam/routes/acs_users.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import AcsUser, AcsEntrance @@ -96,7 +97,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, @@ -218,7 +219,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, @@ -393,7 +394,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, @@ -587,7 +588,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 e14dfe8e..0ff317a1 100644 --- a/seam/routes/action_attempts.py +++ b/seam/routes/action_attempts.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ActionAttempt from ..modules.action_attempts import resolve_action_attempt @@ -30,7 +31,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. @@ -89,7 +90,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/client_sessions.py b/seam/routes/client_sessions.py index 29f996af..b774531e 100644 --- a/seam/routes/client_sessions.py +++ b/seam/routes/client_sessions.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ClientSession diff --git a/seam/routes/connect_webviews.py b/seam/routes/connect_webviews.py index d6b32d6e..7b7fb9be 100644 --- a/seam/routes/connect_webviews.py +++ b/seam/routes/connect_webviews.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ConnectWebview @@ -79,7 +80,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]: @@ -216,7 +217,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 bcc979fb..dd7f7c3a 100644 --- a/seam/routes/connected_accounts.py +++ b/seam/routes/connected_accounts.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ConnectedAccount from .connected_accounts_simulate import ( AbstractConnectedAccountsSimulate, @@ -47,7 +48,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 @@ -162,7 +163,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/connected_accounts_simulate.py b/seam/routes/connected_accounts_simulate.py index 3766b03b..540156df 100644 --- a/seam/routes/connected_accounts_simulate.py +++ b/seam/routes/connected_accounts_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null class AbstractConnectedAccountsSimulate(abc.ABC): diff --git a/seam/routes/customers.py b/seam/routes/customers.py index bdbf4f54..a1425f53 100644 --- a/seam/routes/customers.py +++ b/seam/routes/customers.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import CustomerPortal diff --git a/seam/routes/devices.py b/seam/routes/devices.py index 433a8d40..2539cf5b 100644 --- a/seam/routes/devices.py +++ b/seam/routes/devices.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Device, DeviceProvider from .devices_simulate import AbstractDevicesSimulate, DevicesSimulate from .devices_unmanaged import AbstractDevicesUnmanaged, DevicesUnmanaged @@ -48,10 +49,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 `_. @@ -121,7 +122,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 `_. @@ -194,10 +195,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 `_. @@ -315,7 +316,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_simulate.py b/seam/routes/devices_simulate.py index 2ebaa239..f4756738 100644 --- a/seam/routes/devices_simulate.py +++ b/seam/routes/devices_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null class AbstractDevicesSimulate(abc.ABC): diff --git a/seam/routes/devices_unmanaged.py b/seam/routes/devices_unmanaged.py index 9c0e865f..b6ab2e5e 100644 --- a/seam/routes/devices_unmanaged.py +++ b/seam/routes/devices_unmanaged.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import UnmanagedDevice @@ -38,10 +39,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[UnmanagedDevice]: """Returns a list of all `unmanaged devices `_. @@ -148,10 +149,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[UnmanagedDevice]: """Returns a list of all `unmanaged devices `_. diff --git a/seam/routes/events.py b/seam/routes/events.py index 30ce08e9..1f72d4fc 100644 --- a/seam/routes/events.py +++ b/seam/routes/events.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import SeamEvent @@ -42,7 +43,7 @@ def list( acs_system_id: Optional[str] = None, acs_system_ids: Optional[List[str]] = None, acs_user_id: Optional[str] = None, - between: Optional[List[Dict[str, Any]]] = None, + between: Optional[List[str]] = None, connect_webview_id: Optional[str] = None, connected_account_id: Optional[str] = None, customer_key: Optional[str] = None, @@ -170,7 +171,7 @@ def list( acs_system_id: Optional[str] = None, acs_system_ids: Optional[List[str]] = None, acs_user_id: Optional[str] = None, - between: Optional[List[Dict[str, Any]]] = None, + between: Optional[List[str]] = None, connect_webview_id: Optional[str] = None, connected_account_id: Optional[str] = None, customer_key: Optional[str] = None, diff --git a/seam/routes/instant_keys.py b/seam/routes/instant_keys.py index b1c3bf23..3e9244ed 100644 --- a/seam/routes/instant_keys.py +++ b/seam/routes/instant_keys.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import InstantKey diff --git a/seam/routes/locks.py b/seam/routes/locks.py index 23a41b0d..e8ddd134 100644 --- a/seam/routes/locks.py +++ b/seam/routes/locks.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ActionAttempt, Device from .locks_simulate import AbstractLocksSimulate, LocksSimulate from ..modules.action_attempts import resolve_action_attempt @@ -66,10 +67,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 `locks `_. @@ -232,10 +233,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 `locks `_. diff --git a/seam/routes/locks_simulate.py b/seam/routes/locks_simulate.py index 937cb37d..d203a4d2 100644 --- a/seam/routes/locks_simulate.py +++ b/seam/routes/locks_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ActionAttempt from ..modules.action_attempts import resolve_action_attempt diff --git a/seam/routes/noise_sensors.py b/seam/routes/noise_sensors.py index efd90f41..35b627d0 100644 --- a/seam/routes/noise_sensors.py +++ b/seam/routes/noise_sensors.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Device from .noise_sensors_noise_thresholds import ( AbstractNoiseSensorsNoiseThresholds, @@ -36,10 +37,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 `noise sensors `_. @@ -111,10 +112,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 `noise sensors `_. diff --git a/seam/routes/noise_sensors_noise_thresholds.py b/seam/routes/noise_sensors_noise_thresholds.py index 8c5c9f02..4a1ea6a1 100644 --- a/seam/routes/noise_sensors_noise_thresholds.py +++ b/seam/routes/noise_sensors_noise_thresholds.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import NoiseThreshold diff --git a/seam/routes/noise_sensors_simulate.py b/seam/routes/noise_sensors_simulate.py index 1ce320f2..7671cfc0 100644 --- a/seam/routes/noise_sensors_simulate.py +++ b/seam/routes/noise_sensors_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null class AbstractNoiseSensorsSimulate(abc.ABC): diff --git a/seam/routes/phones.py b/seam/routes/phones.py index 2c19692f..d98737ee 100644 --- a/seam/routes/phones.py +++ b/seam/routes/phones.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Phone from .phones_simulate import AbstractPhonesSimulate, PhonesSimulate diff --git a/seam/routes/phones_simulate.py b/seam/routes/phones_simulate.py index 3144dba7..94178998 100644 --- a/seam/routes/phones_simulate.py +++ b/seam/routes/phones_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Phone diff --git a/seam/routes/spaces.py b/seam/routes/spaces.py index 4ebb7ed4..00c5f54d 100644 --- a/seam/routes/spaces.py +++ b/seam/routes/spaces.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Space, Batch @@ -115,7 +116,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]: @@ -376,7 +377,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 f0741076..dc47ea8c 100644 --- a/seam/routes/thermostats.py +++ b/seam/routes/thermostats.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ActionAttempt, Device from .thermostats_daily_programs import ( AbstractThermostatsDailyPrograms, @@ -84,7 +85,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 `_. @@ -189,10 +190,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 `thermostats `_. @@ -318,10 +319,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. @@ -352,7 +353,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 `_. @@ -387,13 +388,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. @@ -537,7 +538,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 `_. @@ -722,10 +723,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 `thermostats `_. @@ -957,10 +958,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. @@ -1005,7 +1006,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 `_. @@ -1068,13 +1069,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_daily_programs.py b/seam/routes/thermostats_daily_programs.py index 56e886da..f02743be 100644 --- a/seam/routes/thermostats_daily_programs.py +++ b/seam/routes/thermostats_daily_programs.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ThermostatDailyProgram, ActionAttempt from ..modules.action_attempts import resolve_action_attempt diff --git a/seam/routes/thermostats_schedules.py b/seam/routes/thermostats_schedules.py index 24935b3e..cffa62a5 100644 --- a/seam/routes/thermostats_schedules.py +++ b/seam/routes/thermostats_schedules.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ThermostatSchedule @@ -15,7 +16,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 `_. @@ -75,7 +76,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: @@ -111,7 +112,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 `_. @@ -211,7 +212,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/thermostats_simulate.py b/seam/routes/thermostats_simulate.py index 94d2a1be..85eef961 100644 --- a/seam/routes/thermostats_simulate.py +++ b/seam/routes/thermostats_simulate.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null class AbstractThermostatsSimulate(abc.ABC): diff --git a/seam/routes/user_identities.py b/seam/routes/user_identities.py index 819ddd91..f54ef3e4 100644 --- a/seam/routes/user_identities.py +++ b/seam/routes/user_identities.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import ( UserIdentity, InstantKey, @@ -49,10 +50,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 `_. @@ -128,7 +129,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]: @@ -210,10 +211,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 `_. @@ -275,10 +276,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 `_. @@ -402,7 +403,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]: @@ -546,10 +547,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 bc5df14a..6e8bf749 100644 --- a/seam/routes/user_identities_unmanaged.py +++ b/seam/routes/user_identities_unmanaged.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import UnmanagedUserIdentity @@ -21,7 +22,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). @@ -83,7 +84,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/webhooks.py b/seam/routes/webhooks.py index 444bed45..eeeae4b8 100644 --- a/seam/routes/webhooks.py +++ b/seam/routes/webhooks.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Webhook diff --git a/seam/routes/workspaces.py b/seam/routes/workspaces.py index 9b9b9195..7f224c42 100644 --- a/seam/routes/workspaces.py +++ b/seam/routes/workspaces.py @@ -1,6 +1,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient +from ..null import Null from ..resources import Workspace, ActionAttempt from ..modules.action_attempts import resolve_action_attempt @@ -13,7 +14,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, @@ -110,7 +111,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,