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