diff --git a/README.rst b/README.rst index d3f7c52e..7e878f87 100644 --- a/README.rst +++ b/README.rst @@ -61,10 +61,14 @@ Contents * `Webhooks`_ + * `Omitted params and null params`_ + * `Advanced Usage`_ * `Setting the endpoint`_ + * `Serializing URL search params`_ + * `Development and Testing`_ * `Quickstart`_ @@ -425,6 +429,47 @@ see the `Svix docs for more examples in specific frameworks '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 omitted, +while params set to ``seam.NULL`` are serialized to an empty value, e.g., ``a=``. +See `Omitted params and null params`_. +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/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/__init__.py b/seam/__init__.py index f93f29d1..0e0a2b01 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 .null import NULL, Null +from .utils.url_search_params_serializer import ( + UnserializableParamError, + UrlSearchParams, + serialize_url_search_params, + update_url_search_params, +) diff --git a/seam/client.py b/seam/client.py index 2ac9f3ae..a0e93efa 100644 --- a/seam/client.py +++ b/seam/client.py @@ -11,6 +11,7 @@ SeamHttpInvalidInputError, SeamHttpUnauthorizedError, ) +from .null import replace_null SDK_HEADERS = { "seam-sdk-name": "seamapi/python", @@ -59,6 +60,12 @@ def __init__( def request(self, method, url, *args, **kwargs): url = urljoin(self.base_url, url) + + # Route methods omit params set to None, so any remaining NULL sentinel + # is an explicit null and becomes None for JSON serialization. + if "json" in kwargs: + kwargs["json"] = replace_null(kwargs["json"]) + response = super().request(method, url, *args, **kwargs) return self._handle_response(response) diff --git a/seam/null.py b/seam/null.py new file mode 100644 index 00000000..fe8bc4d8 --- /dev/null +++ b/seam/null.py @@ -0,0 +1,95 @@ +"""The explicit null sentinel used by request params. + +Python has a single absence value, ``None``, but the Seam API distinguishes +an omitted param from a param explicitly set to null. For example, in an +update request, an omitted param leaves the current value unchanged, +while a null param unsets the current value. + +Since sending null is rarely intended and unsetting a value cannot be undone, +``None`` means the safe option of omitting the param. +Sending null is explicit and always spelled :data:`NULL`. +""" + +from collections.abc import Mapping +from typing import Any + + +class Null: + """Type of the :data:`NULL` sentinel.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self): + return "NULL" + + def __bool__(self): + return False + + +NULL = Null() +"""Sentinel for a param explicitly set to null. + +Params set to this sentinel are sent as null, +whereas params set to ``None`` are omitted from the request. + +Use it wherever the Seam API documents null as a meaningful value, e.g., +to unset a value in an update request, or to filter by an unset value: + +.. code-block:: python + + from seam import NULL, Seam + + seam = Seam() + + # Unsets the name, leaving custom_metadata unchanged. + seam.devices.update(device_id=device_id, name=NULL) + + # Lists only the Access Grants which have no access_grant_key. + seam.access_grants.list(access_grant_key=NULL) + +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. +""" + + +def is_null(value: Any) -> 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/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, diff --git a/seam/utils/url_search_params_serializer.py b/seam/utils/url_search_params_serializer.py new file mode 100644 index 00000000..0bdfcdeb --- /dev/null +++ b/seam/utils/url_search_params_serializer.py @@ -0,0 +1,435 @@ +"""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 ``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``. +- 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 + +from ..null import is_null + +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 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 value is None: + 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_null(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 is_null(value): + 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 _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/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 new file mode 100644 index 00000000..6ce3a8e2 --- /dev/null +++ b/test/url_search_params_serializer_test.py @@ -0,0 +1,454 @@ +from collections import OrderedDict +from datetime import date, datetime, timedelta, timezone + +import pytest + +from seam.null import NULL +from seam.utils.url_search_params_serializer import ( + 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_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" + + +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_none_params(): + assert serialize_url_search_params({"bar": None}) == "" + assert serialize_url_search_params({"foo": 1, "bar": None}) == "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(): + 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": NULL}}}} + ) == ("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": NULL, "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", NULL]}) + + 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 [None, "", {}]: + 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"