diff --git a/README.rst b/README.rst index a031c5ce..b8d092d0 100644 --- a/README.rst +++ b/README.rst @@ -63,6 +63,8 @@ Contents * `Webhooks`_ + * `Omitted params and null params`_ + * `Advanced Usage`_ * `Setting the endpoint`_ @@ -73,6 +75,8 @@ Contents * `Configuring the httpx client`_ + * `Serializing URL search params`_ + * `Development and Testing`_ * `Quickstart`_ @@ -447,6 +451,47 @@ see the `Svix docs for more examples in specific frameworks [('device_ids', 'device1'), ('device_ids', 'device2')] + + str(search_params) + # => 'device_ids=device1&device_ids=device2' + +Pass either the query string or the pairs to your HTTP client. +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. + +The Seam API parses these params with the corresponding `parser`_. + +.. _URLSearchParams: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams + +.. _reference implementation: https://github.com/seamapi/url-search-params-serializer +.. _parser: https://github.com/seamapi/url-search-params-parser + 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/partials/route-method.hbs b/codegen/layouts/partials/route-method.hbs index b615e7f8..50c5fe44 100644 --- a/codegen/layouts/partials/route-method.hbs +++ b/codegen/layouts/partials/route-method.hbs @@ -13,6 +13,7 @@ raise ValueError("At least one parameter is required for {{path}}") {{/if}} + {{#unless (eq returnType "None")}}res = {{/unless}}self.client.{{httpVerb}}("{{path}}", {{payloadArg}}={{payloadVar}}) {{#if (eq returnType "ActionAttempt")}} diff --git a/codegen/layouts/route.hbs b/codegen/layouts/route.hbs index afaf2b43..f705b81d 100644 --- a/codegen/layouts/route.hbs +++ b/codegen/layouts/route.hbs @@ -2,6 +2,7 @@ from typing import Optional, Any, List, Dict, Union import abc from ..client import SeamHttpClient from ..route import route_metadata +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 9ecca230..3945c982 100644 --- a/codegen/lib/class-model.ts +++ b/codegen/lib/class-model.ts @@ -9,12 +9,13 @@ export interface ClassMethodParameter { deprecationMessage: string position?: number | undefined required?: boolean | undefined + isNullable?: boolean | undefined } export interface ClassMethod { methodName: string path: string - preferredMethod: string + semanticMethod: string hasRequiredParameters: boolean hasPagination: boolean description: string 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 be784fdc..94a12452 100644 --- a/codegen/lib/layouts/route.ts +++ b/codegen/lib/layouts/route.ts @@ -27,6 +27,7 @@ export interface MethodLayoutContext { isDeprecated: boolean deprecationMessage: string required: boolean + isNullable: boolean }> returnPath: string[] returnType: string @@ -56,12 +57,14 @@ export interface RouteLayoutContext { methods: MethodLayoutContext[] } +// GET and DELETE carry their params in the query string, as the OpenAPI +// operations for those methods declare them; the rest read a JSON body. const getRequestLayoutContext = ( - preferredMethod: string, + semanticMethod: string, ): Pick => { - const httpVerb = preferredMethod.toLowerCase() + const httpVerb = semanticMethod.toLowerCase() - if (preferredMethod === 'GET' || preferredMethod === 'DELETE') { + if (semanticMethod === 'GET' || semanticMethod === 'DELETE') { return { httpVerb, payloadVar: 'params', payloadArg: 'params' } } @@ -73,7 +76,7 @@ export const getMethodLayoutContext = ( ): MethodLayoutContext => ({ name: method.methodName, path: method.path, - ...getRequestLayoutContext(method.preferredMethod), + ...getRequestLayoutContext(method.semanticMethod), hasRequiredParameters: method.hasRequiredParameters, hasPagination: method.hasPagination, description: method.description, @@ -87,6 +90,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 2d5f33d1..fb1c44d2 100644 --- a/codegen/lib/routes.ts +++ b/codegen/lib/routes.ts @@ -89,7 +89,7 @@ export const routes = ( cls.methods.push({ methodName: endpoint.name, path: endpoint.path, - preferredMethod: endpoint.request.preferredMethod, + semanticMethod: endpoint.request.semanticMethod, hasRequiredParameters: endpoint.request.hasRequiredParameters, hasPagination: endpoint.hasPagination, description: endpoint.description, @@ -104,6 +104,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 3e6a6f45..9f3147c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "@seamapi/python", "devDependencies": { "@seamapi/blueprint": "^1.5.1", - "@seamapi/fake-seam-connect": "1.86.0", + "@seamapi/fake-seam-connect": "2.0.3", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", "change-case": "^5.4.4", @@ -19,9 +19,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -37,9 +37,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -55,9 +55,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -91,9 +91,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -109,9 +109,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -127,9 +127,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -145,9 +145,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -163,9 +163,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -181,9 +181,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -199,9 +199,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -217,9 +217,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -235,9 +235,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -271,9 +271,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -289,9 +289,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -307,9 +307,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -325,9 +325,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -343,9 +343,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -361,9 +361,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -397,9 +397,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -415,9 +415,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -433,9 +433,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -451,9 +451,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -469,9 +469,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -801,38 +801,20 @@ "npm": ">=10.0.0" } }, - "node_modules/@seamapi/fake-devicedb": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@seamapi/fake-devicedb/-/fake-devicedb-1.6.1.tgz", - "integrity": "sha512-w4Ar/s2kPnE5ExJSlpD3sKL8lkF+rLHRROArIRxtR2reHfnDSVwnDt9TzBYkHqgMP/x4o7LUzlRRpobn2xn24A==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18.12.0", - "npm": ">= 9.0.0" - }, - "optionalDependencies": { - "zod": "^3.21.4", - "zustand": "^4.3.7", - "zustand-hoist": "^2.0.0" - } - }, "node_modules/@seamapi/fake-seam-connect": { - "version": "1.86.0", - "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-1.86.0.tgz", - "integrity": "sha512-iO5fwtSIPhzmIiLxrFDtCYF/7HTb+ywcGmc3WzWt8Sr0bLQlmwCyTJ8YYZy++Hx0FsnNpa5AmlJuJGul9Y5gZA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-2.0.3.tgz", + "integrity": "sha512-XJsdSBvBNpm/k7CUttFSOxM41WlOY/65bTXCdUKewr5k1Pj31g2Dmz2mVUCNB5nPgLTC4gXbzpB+2eyakzkD0A==", "dev": true, "license": "MIT", "bin": { "fake-seam-connect": "dist/server.js" }, "engines": { - "node": ">=18.12.0", - "npm": ">= 9.0.0" + "node": ">=22.12.0", + "npm": ">=10.0.0" }, "optionalDependencies": { - "@seamapi/fake-devicedb": ">=1.0.0-rc.0", "zustand": "^4.3.7", "zustand-hoist": "^2.0.0" } @@ -965,17 +947,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -988,7 +970,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1004,16 +986,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1029,14 +1011,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1051,14 +1033,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1069,9 +1051,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -1086,15 +1068,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1111,9 +1093,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -1125,16 +1107,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1163,9 +1145,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { @@ -1205,16 +1187,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1229,13 +1211,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1527,9 +1509,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1886,9 +1868,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "version": "5.24.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz", + "integrity": "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2099,9 +2081,9 @@ } }, "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2113,32 +2095,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-string-regexp": { @@ -2747,9 +2729,9 @@ } }, "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC", "peer": true @@ -2898,9 +2880,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", - "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -2953,9 +2935,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { @@ -3101,9 +3083,9 @@ } }, "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "peer": true, @@ -3817,9 +3799,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4190,9 +4172,9 @@ } }, "node_modules/neostandard/node_modules/globals": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", - "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, "license": "MIT", "engines": { @@ -5453,9 +5435,9 @@ } }, "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "peer": true, @@ -5580,16 +5562,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 83d09c74..f02e9eb2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@seamapi/blueprint": "^1.5.1", - "@seamapi/fake-seam-connect": "1.86.0", + "@seamapi/fake-seam-connect": "2.0.3", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", "change-case": "^5.4.4", diff --git a/seam/__init__.py b/seam/__init__.py index 30e17b6c..4c912626 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 .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 723037f8..97f44ab6 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", @@ -86,6 +89,8 @@ def _init_proxy_transport(self, *args, **kwargs) -> httpx.BaseTransport: # httpx.Client promises, so the verb helpers routed through it have to # say so too. Without these overrides callers see the inherited Response # type and indexing the returned payload does not type check. + # httpx also omits json from its get and delete signatures, though the + # Seam API reads the params of a delete from the request body. def get(self, url, **kwargs) -> Any: return self.request("GET", url, **kwargs) @@ -102,6 +107,18 @@ def delete(self, url, json=None, **kwargs) -> Any: return self.request("DELETE", url, json=json, **kwargs) def request(self, method, url, *args, **kwargs) -> Any: + # 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"]) + + # Search params are serialized to the Seam API standard, which httpx + # does not implement. The NULL sentinel is serialized to an empty value. + # httpx percent-encodes a few characters differently than the standard + # when it re-encodes the query, which the Seam API reads the same way. + if isinstance(kwargs.get("params"), Mapping): + kwargs["params"] = serialize_url_search_params(kwargs["params"]) + response = super().request(method, url, *args, **kwargs) return self._handle_response(response) diff --git a/seam/modules/action_attempts.py b/seam/modules/action_attempts.py index d764d3cb..07a9efb6 100644 --- a/seam/modules/action_attempts.py +++ b/seam/modules/action_attempts.py @@ -10,8 +10,8 @@ def get_action_attempt(client: SeamHttpClient, action_attempt_id: str) -> ActionAttempt: - res = client.post( - "/action_attempts/get", json={"action_attempt_id": action_attempt_id} + res = client.get( + "/action_attempts/get", params={"action_attempt_id": action_attempt_id} ) return ActionAttempt.from_dict(res["action_attempt"]) 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 82231033..82f909de 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]: @@ -735,35 +736,35 @@ def list( :returns: OK :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_code_ids is not None: - json_payload["access_code_ids"] = access_code_ids + params["access_code_ids"] = access_code_ids if access_grant_id is not None: - json_payload["access_grant_id"] = access_grant_id + params["access_grant_id"] = access_grant_id if access_grant_key is not None: - json_payload["access_grant_key"] = access_grant_key + params["access_grant_key"] = access_grant_key if access_method_id is not None: - json_payload["access_method_id"] = access_method_id + params["access_method_id"] = access_method_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_id is not None: - json_payload["device_id"] = device_id + params["device_id"] = device_id if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search if user_identifier_key is not None: - json_payload["user_identifier_key"] = user_identifier_key + params["user_identifier_key"] = user_identifier_key - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /access_codes/list" ) - res = self.client.post("/access_codes/list", json=json_payload) + res = self.client.get("/access_codes/list", params=params) return [AccessCode.from_dict(item) for item in res["access_codes"]] diff --git a/seam/routes/access_codes_simulate.py b/seam/routes/access_codes_simulate.py index 4c4c756c..98e486dd 100644 --- a/seam/routes/access_codes_simulate.py +++ b/seam/routes/access_codes_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +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 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..b8d320df 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, @@ -406,23 +407,23 @@ def get_related( :returns: OK :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_grant_ids is not None: - json_payload["access_grant_ids"] = access_grant_ids + params["access_grant_ids"] = access_grant_ids if access_grant_keys is not None: - json_payload["access_grant_keys"] = access_grant_keys + params["access_grant_keys"] = access_grant_keys if exclude is not None: - json_payload["exclude"] = exclude + params["exclude"] = exclude if include is not None: - json_payload["include"] = include + params["include"] = include - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /access_grants/get_related" ) - res = self.client.post("/access_grants/get_related", json=json_payload) + res = self.client.get("/access_grants/get_related", params=params) return Batch.from_dict(res["batch"]) @@ -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, @@ -475,36 +476,36 @@ def list( :param user_identity_id: ID of user identity by which you want to filter the list of Access Grants. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_code_id is not None: - json_payload["access_code_id"] = access_code_id + params["access_code_id"] = access_code_id if access_grant_ids is not None: - json_payload["access_grant_ids"] = access_grant_ids + params["access_grant_ids"] = access_grant_ids if access_grant_key is not None: - json_payload["access_grant_key"] = access_grant_key + params["access_grant_key"] = access_grant_key if acs_entrance_id is not None: - json_payload["acs_entrance_id"] = acs_entrance_id + params["acs_entrance_id"] = acs_entrance_id if acs_system_id is not None: - json_payload["acs_system_id"] = acs_system_id + params["acs_system_id"] = acs_system_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_id is not None: - json_payload["device_id"] = device_id + params["device_id"] = device_id if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if location_id is not None: - json_payload["location_id"] = location_id + params["location_id"] = location_id if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if reservation_key is not None: - json_payload["reservation_key"] = reservation_key + params["reservation_key"] = reservation_key if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id if user_identity_id is not None: - json_payload["user_identity_id"] = user_identity_id + params["user_identity_id"] = user_identity_id - res = self.client.post("/access_grants/list", json=json_payload) + res = self.client.get("/access_grants/list", params=params) return [AccessGrant.from_dict(item) for item in res["access_grants"]] @@ -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..483462e6 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. @@ -363,21 +364,21 @@ def get_related( :returns: OK :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_method_ids is not None: - json_payload["access_method_ids"] = access_method_ids + params["access_method_ids"] = access_method_ids if exclude is not None: - json_payload["exclude"] = exclude + params["exclude"] = exclude if include is not None: - json_payload["include"] = include + params["include"] = include - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /access_methods/get_related" ) - res = self.client.post("/access_methods/get_related", json=json_payload) + res = self.client.get("/access_methods/get_related", params=params) return Batch.from_dict(res["batch"]) @@ -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/access_methods_unmanaged.py b/seam/routes/access_methods_unmanaged.py index fd7cd14a..b376eb86 100644 --- a/seam/routes/access_methods_unmanaged.py +++ b/seam/routes/access_methods_unmanaged.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import UnmanagedAccessMethod diff --git a/seam/routes/acs.py b/seam/routes/acs.py index 125f3c31..c9cefdc6 100644 --- a/seam/routes/acs.py +++ b/seam/routes/acs.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +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 6b84487e..a01a1826 100644 --- a/seam/routes/acs_access_groups.py +++ b/seam/routes/acs_access_groups.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +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 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..be2eb326 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 `_. @@ -235,20 +236,20 @@ def list( :param page_cursor: Identifies the specific page of results to return, obtained from the previous page's ``next_page_cursor``. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if acs_system_id is not None: - json_payload["acs_system_id"] = acs_system_id + params["acs_system_id"] = acs_system_id if acs_system_ids is not None: - json_payload["acs_system_ids"] = acs_system_ids + params["acs_system_ids"] = acs_system_ids if acs_encoder_ids is not None: - json_payload["acs_encoder_ids"] = acs_encoder_ids + params["acs_encoder_ids"] = acs_encoder_ids if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor - res = self.client.post("/acs/encoders/list", json=json_payload) + res = self.client.get("/acs/encoders/list", params=params) return [AcsEncoder.from_dict(item) for item in res["acs_encoders"]] diff --git a/seam/routes/acs_encoders_simulate.py b/seam/routes/acs_encoders_simulate.py index ac0d1793..099e8e4f 100644 --- a/seam/routes/acs_encoders_simulate.py +++ b/seam/routes/acs_encoders_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null class AbstractAcsEncodersSimulate(abc.ABC): diff --git a/seam/routes/acs_entrances.py b/seam/routes/acs_entrances.py index 353929ca..92fd2bc1 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]: @@ -230,32 +231,32 @@ def list( :param space_id: ID of the space for which you want to list entrances. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_method_id is not None: - json_payload["access_method_id"] = access_method_id + params["access_method_id"] = access_method_id if acs_credential_id is not None: - json_payload["acs_credential_id"] = acs_credential_id + params["acs_credential_id"] = acs_credential_id if acs_entrance_ids is not None: - json_payload["acs_entrance_ids"] = acs_entrance_ids + params["acs_entrance_ids"] = acs_entrance_ids if acs_system_id is not None: - json_payload["acs_system_id"] = acs_system_id + params["acs_system_id"] = acs_system_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if location_id is not None: - json_payload["location_id"] = location_id + params["location_id"] = location_id if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id - res = self.client.post("/acs/entrances/list", json=json_payload) + res = self.client.get("/acs/entrances/list", params=params) return [AcsEntrance.from_dict(item) for item in res["acs_entrances"]] @@ -276,20 +277,20 @@ def list_credentials_with_access( :returns: OK :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if acs_entrance_id is not None: - json_payload["acs_entrance_id"] = acs_entrance_id + params["acs_entrance_id"] = acs_entrance_id if include_if is not None: - json_payload["include_if"] = include_if + params["include_if"] = include_if - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /acs/entrances/list_credentials_with_access" ) - res = self.client.post( - "/acs/entrances/list_credentials_with_access", json=json_payload + res = self.client.get( + "/acs/entrances/list_credentials_with_access", params=params ) return [AcsCredential.from_dict(item) for item in res["acs_credentials"]] diff --git a/seam/routes/acs_systems.py b/seam/routes/acs_systems.py index 7eb41612..767c062f 100644 --- a/seam/routes/acs_systems.py +++ b/seam/routes/acs_systems.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import AcsSystem 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..b4cfd7f7 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. @@ -118,17 +119,17 @@ def list( :param page_cursor: Identifies the specific page of results to return, obtained from the previous page's ``next_page_cursor``. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if action_attempt_ids is not None: - json_payload["action_attempt_ids"] = action_attempt_ids + params["action_attempt_ids"] = action_attempt_ids if device_id is not None: - json_payload["device_id"] = device_id + params["device_id"] = device_id if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor - res = self.client.post("/action_attempts/list", json=json_payload) + res = self.client.get("/action_attempts/list", params=params) return [ActionAttempt.from_dict(item) for item in res["action_attempts"]] diff --git a/seam/routes/client_sessions.py b/seam/routes/client_sessions.py index 62da3156..83c3ca30 100644 --- a/seam/routes/client_sessions.py +++ b/seam/routes/client_sessions.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import ClientSession diff --git a/seam/routes/connect_webviews.py b/seam/routes/connect_webviews.py index 2310a432..e7785711 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]: @@ -272,21 +273,21 @@ def list( :param user_identifier_key: Your user ID for the user by which you want to filter Connect Webviews. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if custom_metadata_has is not None: - json_payload["custom_metadata_has"] = custom_metadata_has + params["custom_metadata_has"] = custom_metadata_has if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search if user_identifier_key is not None: - json_payload["user_identifier_key"] = user_identifier_key + params["user_identifier_key"] = user_identifier_key - res = self.client.post("/connect_webviews/list", json=json_payload) + res = self.client.get("/connect_webviews/list", params=params) return [ConnectWebview.from_dict(item) for item in res["connect_webviews"]] diff --git a/seam/routes/connected_accounts.py b/seam/routes/connected_accounts.py index 1d8a6a6f..4ab88a7d 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, @@ -218,24 +219,24 @@ def list( :param user_identifier_key: Your user ID for the user by which you want to filter connected accounts. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if custom_metadata_has is not None: - json_payload["custom_metadata_has"] = custom_metadata_has + params["custom_metadata_has"] = custom_metadata_has if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id if user_identifier_key is not None: - json_payload["user_identifier_key"] = user_identifier_key + params["user_identifier_key"] = user_identifier_key - res = self.client.post("/connected_accounts/list", json=json_payload) + res = self.client.get("/connected_accounts/list", params=params) return [ConnectedAccount.from_dict(item) for item in res["connected_accounts"]] diff --git a/seam/routes/connected_accounts_simulate.py b/seam/routes/connected_accounts_simulate.py index 76df8f62..a92f0cf1 100644 --- a/seam/routes/connected_accounts_simulate.py +++ b/seam/routes/connected_accounts_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null class AbstractConnectedAccountsSimulate(abc.ABC): diff --git a/seam/routes/customers.py b/seam/routes/customers.py index 494848c5..45cc87f6 100644 --- a/seam/routes/customers.py +++ b/seam/routes/customers.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import CustomerPortal @@ -334,48 +335,48 @@ def delete_data( :param user_identity_keys: List of user identity keys to delete. :param user_keys: List of user keys to delete.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_grant_keys is not None: - json_payload["access_grant_keys"] = access_grant_keys + params["access_grant_keys"] = access_grant_keys if booking_keys is not None: - json_payload["booking_keys"] = booking_keys + params["booking_keys"] = booking_keys if building_keys is not None: - json_payload["building_keys"] = building_keys + params["building_keys"] = building_keys if common_area_keys is not None: - json_payload["common_area_keys"] = common_area_keys + params["common_area_keys"] = common_area_keys if customer_keys is not None: - json_payload["customer_keys"] = customer_keys + params["customer_keys"] = customer_keys if facility_keys is not None: - json_payload["facility_keys"] = facility_keys + params["facility_keys"] = facility_keys if guest_keys is not None: - json_payload["guest_keys"] = guest_keys + params["guest_keys"] = guest_keys if listing_keys is not None: - json_payload["listing_keys"] = listing_keys + params["listing_keys"] = listing_keys if property_keys is not None: - json_payload["property_keys"] = property_keys + params["property_keys"] = property_keys if property_listing_keys is not None: - json_payload["property_listing_keys"] = property_listing_keys + params["property_listing_keys"] = property_listing_keys if reservation_keys is not None: - json_payload["reservation_keys"] = reservation_keys + params["reservation_keys"] = reservation_keys if resident_keys is not None: - json_payload["resident_keys"] = resident_keys + params["resident_keys"] = resident_keys if room_keys is not None: - json_payload["room_keys"] = room_keys + params["room_keys"] = room_keys if space_keys is not None: - json_payload["space_keys"] = space_keys + params["space_keys"] = space_keys if staff_member_keys is not None: - json_payload["staff_member_keys"] = staff_member_keys + params["staff_member_keys"] = staff_member_keys if tenant_keys is not None: - json_payload["tenant_keys"] = tenant_keys + params["tenant_keys"] = tenant_keys if unit_keys is not None: - json_payload["unit_keys"] = unit_keys + params["unit_keys"] = unit_keys if user_identity_keys is not None: - json_payload["user_identity_keys"] = user_identity_keys + params["user_identity_keys"] = user_identity_keys if user_keys is not None: - json_payload["user_keys"] = user_keys + params["user_keys"] = user_keys - self.client.post("/customers/delete_data", json=json_payload) + self.client.delete("/customers/delete_data", params=params) return None diff --git a/seam/routes/devices.py b/seam/routes/devices.py index 8fe8841c..519d619b 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 `_. @@ -253,42 +254,42 @@ def list( :param user_identifier_key: Your own internal user ID for the user for which you want to list devices. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if connect_webview_id is not None: - json_payload["connect_webview_id"] = connect_webview_id + params["connect_webview_id"] = connect_webview_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if connected_account_ids is not None: - json_payload["connected_account_ids"] = connected_account_ids + params["connected_account_ids"] = connected_account_ids if created_before is not None: - json_payload["created_before"] = created_before + params["created_before"] = created_before if custom_metadata_has is not None: - json_payload["custom_metadata_has"] = custom_metadata_has + params["custom_metadata_has"] = custom_metadata_has if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_ids is not None: - json_payload["device_ids"] = device_ids + params["device_ids"] = device_ids if device_type is not None: - json_payload["device_type"] = device_type + params["device_type"] = device_type if device_types is not None: - json_payload["device_types"] = device_types + params["device_types"] = device_types if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if manufacturer is not None: - json_payload["manufacturer"] = manufacturer + params["manufacturer"] = manufacturer if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id if unstable_location_id is not None: - json_payload["unstable_location_id"] = unstable_location_id + params["unstable_location_id"] = unstable_location_id if user_identifier_key is not None: - json_payload["user_identifier_key"] = user_identifier_key + params["user_identifier_key"] = user_identifier_key - res = self.client.post("/devices/list", json=json_payload) + res = self.client.get("/devices/list", params=params) return [Device.from_dict(item) for item in res["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_simulate.py b/seam/routes/devices_simulate.py index dfdfdfed..57b2d418 100644 --- a/seam/routes/devices_simulate.py +++ b/seam/routes/devices_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null class AbstractDevicesSimulate(abc.ABC): diff --git a/seam/routes/devices_unmanaged.py b/seam/routes/devices_unmanaged.py index 4b8b5792..dbe81f49 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 `_. @@ -188,34 +189,34 @@ def list( :param search: String for which to search. Filters returned devices to include all records that satisfy a partial match using ``device_id`` (full or partial UUID prefix, minimum 4 characters), ``connected_account_id``, ``display_name``, ``custom_metadata`` or ``location.location_name``. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if connect_webview_id is not None: - json_payload["connect_webview_id"] = connect_webview_id + params["connect_webview_id"] = connect_webview_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if connected_account_ids is not None: - json_payload["connected_account_ids"] = connected_account_ids + params["connected_account_ids"] = connected_account_ids if created_before is not None: - json_payload["created_before"] = created_before + params["created_before"] = created_before if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_ids is not None: - json_payload["device_ids"] = device_ids + params["device_ids"] = device_ids if device_type is not None: - json_payload["device_type"] = device_type + params["device_type"] = device_type if device_types is not None: - json_payload["device_types"] = device_types + params["device_types"] = device_types if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if manufacturer is not None: - json_payload["manufacturer"] = manufacturer + params["manufacturer"] = manufacturer if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search - res = self.client.post("/devices/unmanaged/list", json=json_payload) + res = self.client.get("/devices/unmanaged/list", params=params) return [UnmanagedDevice.from_dict(item) for item in res["devices"]] diff --git a/seam/routes/events.py b/seam/routes/events.py index 8613b639..f889ae49 100644 --- a/seam/routes/events.py +++ b/seam/routes/events.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import SeamEvent @@ -263,68 +264,68 @@ def list( :returns: OK :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if access_code_id is not None: - json_payload["access_code_id"] = access_code_id + params["access_code_id"] = access_code_id if access_code_ids is not None: - json_payload["access_code_ids"] = access_code_ids + params["access_code_ids"] = access_code_ids if access_grant_id is not None: - json_payload["access_grant_id"] = access_grant_id + params["access_grant_id"] = access_grant_id if access_grant_ids is not None: - json_payload["access_grant_ids"] = access_grant_ids + params["access_grant_ids"] = access_grant_ids if access_method_id is not None: - json_payload["access_method_id"] = access_method_id + params["access_method_id"] = access_method_id if access_method_ids is not None: - json_payload["access_method_ids"] = access_method_ids + params["access_method_ids"] = access_method_ids if acs_access_group_id is not None: - json_payload["acs_access_group_id"] = acs_access_group_id + params["acs_access_group_id"] = acs_access_group_id if acs_credential_id is not None: - json_payload["acs_credential_id"] = acs_credential_id + params["acs_credential_id"] = acs_credential_id if acs_encoder_id is not None: - json_payload["acs_encoder_id"] = acs_encoder_id + params["acs_encoder_id"] = acs_encoder_id if acs_entrance_id is not None: - json_payload["acs_entrance_id"] = acs_entrance_id + params["acs_entrance_id"] = acs_entrance_id if acs_system_id is not None: - json_payload["acs_system_id"] = acs_system_id + params["acs_system_id"] = acs_system_id if acs_system_ids is not None: - json_payload["acs_system_ids"] = acs_system_ids + params["acs_system_ids"] = acs_system_ids if acs_user_id is not None: - json_payload["acs_user_id"] = acs_user_id + params["acs_user_id"] = acs_user_id if between is not None: - json_payload["between"] = between + params["between"] = between if connect_webview_id is not None: - json_payload["connect_webview_id"] = connect_webview_id + params["connect_webview_id"] = connect_webview_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_id is not None: - json_payload["device_id"] = device_id + params["device_id"] = device_id if device_ids is not None: - json_payload["device_ids"] = device_ids + params["device_ids"] = device_ids if event_ids is not None: - json_payload["event_ids"] = event_ids + params["event_ids"] = event_ids if event_type is not None: - json_payload["event_type"] = event_type + params["event_type"] = event_type if event_types is not None: - json_payload["event_types"] = event_types + params["event_types"] = event_types if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if since is not None: - json_payload["since"] = since + params["since"] = since if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id if space_ids is not None: - json_payload["space_ids"] = space_ids + params["space_ids"] = space_ids if unstable_offset is not None: - json_payload["unstable_offset"] = unstable_offset + params["unstable_offset"] = unstable_offset if user_identity_id is not None: - json_payload["user_identity_id"] = user_identity_id + params["user_identity_id"] = user_identity_id - if not json_payload: + if not params: raise ValueError("At least one parameter is required for /events/list") - res = self.client.post("/events/list", json=json_payload) + res = self.client.get("/events/list", params=params) return [SeamEvent.from_dict(item) for item in res["events"]] diff --git a/seam/routes/instant_keys.py b/seam/routes/instant_keys.py index 48787987..eb15278d 100644 --- a/seam/routes/instant_keys.py +++ b/seam/routes/instant_keys.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import InstantKey diff --git a/seam/routes/locks.py b/seam/routes/locks.py index fdf7843f..45a7577c 100644 --- a/seam/routes/locks.py +++ b/seam/routes/locks.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 .locks_simulate import AbstractLocksSimulate, LocksSimulate from ..modules.action_attempts import resolve_action_attempt @@ -245,22 +246,22 @@ def list( :param manufacturer: Manufacturer of the locks that you want to list. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if connect_webview_id is not None: - json_payload["connect_webview_id"] = connect_webview_id + params["connect_webview_id"] = connect_webview_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_type is not None: - json_payload["device_type"] = device_type + params["device_type"] = device_type if device_types is not None: - json_payload["device_types"] = device_types + params["device_types"] = device_types if manufacturer is not None: - json_payload["manufacturer"] = manufacturer + params["manufacturer"] = manufacturer - res = self.client.post("/locks/list", json=json_payload) + res = self.client.get("/locks/list", params=params) return [Device.from_dict(item) for item in res["devices"]] diff --git a/seam/routes/locks_simulate.py b/seam/routes/locks_simulate.py index 6979c9c2..932c68fb 100644 --- a/seam/routes/locks_simulate.py +++ b/seam/routes/locks_simulate.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 diff --git a/seam/routes/noise_sensors.py b/seam/routes/noise_sensors.py index 6ab4492b..f3db9f02 100644 --- a/seam/routes/noise_sensors.py +++ b/seam/routes/noise_sensors.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import Device from .noise_sensors_noise_thresholds import ( AbstractNoiseSensorsNoiseThresholds, @@ -96,21 +97,21 @@ def list( :param manufacturer: Manufacturers of the noise sensors that you want to list. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if connect_webview_id is not None: - json_payload["connect_webview_id"] = connect_webview_id + params["connect_webview_id"] = connect_webview_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_type is not None: - json_payload["device_type"] = device_type + params["device_type"] = device_type if device_types is not None: - json_payload["device_types"] = device_types + params["device_types"] = device_types if manufacturer is not None: - json_payload["manufacturer"] = manufacturer + params["manufacturer"] = manufacturer - res = self.client.post("/noise_sensors/list", json=json_payload) + res = self.client.get("/noise_sensors/list", params=params) return [Device.from_dict(item) for item in res["devices"]] diff --git a/seam/routes/noise_sensors_noise_thresholds.py b/seam/routes/noise_sensors_noise_thresholds.py index 54c90ce8..0a076ada 100644 --- a/seam/routes/noise_sensors_noise_thresholds.py +++ b/seam/routes/noise_sensors_noise_thresholds.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +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 6c527582..edf047f1 100644 --- a/seam/routes/noise_sensors_simulate.py +++ b/seam/routes/noise_sensors_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null class AbstractNoiseSensorsSimulate(abc.ABC): diff --git a/seam/routes/phones.py b/seam/routes/phones.py index a00d49f4..cabca85b 100644 --- a/seam/routes/phones.py +++ b/seam/routes/phones.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +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 58b47aca..183f5870 100644 --- a/seam/routes/phones_simulate.py +++ b/seam/routes/phones_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import Phone diff --git a/seam/routes/spaces.py b/seam/routes/spaces.py index b72ea972..109639a2 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]: @@ -437,23 +438,23 @@ def get_related( :returns: OK :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if exclude is not None: - json_payload["exclude"] = exclude + params["exclude"] = exclude if include is not None: - json_payload["include"] = include + params["include"] = include if space_ids is not None: - json_payload["space_ids"] = space_ids + params["space_ids"] = space_ids if space_keys is not None: - json_payload["space_keys"] = space_keys + params["space_keys"] = space_keys - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /spaces/get_related" ) - res = self.client.post("/spaces/get_related", json=json_payload) + res = self.client.get("/spaces/get_related", params=params) return Batch.from_dict(res["batch"]) @@ -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]: @@ -514,19 +515,19 @@ def remove_acs_entrances( :param space_id: ID of the space from which you want to remove entrances. :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if acs_entrance_ids is not None: - json_payload["acs_entrance_ids"] = acs_entrance_ids + params["acs_entrance_ids"] = acs_entrance_ids if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /spaces/remove_acs_entrances" ) - self.client.post("/spaces/remove_acs_entrances", json=json_payload) + self.client.delete("/spaces/remove_acs_entrances", params=params) return None @@ -574,19 +575,19 @@ def remove_devices(self, *, device_ids: List[str], space_id: str) -> None: :param space_id: ID of the space from which you want to remove devices. :raises ValueError: At least one parameter must be provided.""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if device_ids is not None: - json_payload["device_ids"] = device_ids + params["device_ids"] = device_ids if space_id is not None: - json_payload["space_id"] = space_id + params["space_id"] = space_id - if not json_payload: + if not params: raise ValueError( "At least one parameter is required for /spaces/remove_devices" ) - self.client.post("/spaces/remove_devices", json=json_payload) + self.client.delete("/spaces/remove_devices", params=params) return None diff --git a/seam/routes/thermostats.py b/seam/routes/thermostats.py index b5ec077f..71bc8e57 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 `_. @@ -790,22 +791,22 @@ def list( :param manufacturer: Manufacturer by which you want to filter thermostat devices. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if connect_webview_id is not None: - json_payload["connect_webview_id"] = connect_webview_id + params["connect_webview_id"] = connect_webview_id if connected_account_id is not None: - json_payload["connected_account_id"] = connected_account_id + params["connected_account_id"] = connected_account_id if customer_key is not None: - json_payload["customer_key"] = customer_key + params["customer_key"] = customer_key if device_type is not None: - json_payload["device_type"] = device_type + params["device_type"] = device_type if device_types is not None: - json_payload["device_types"] = device_types + params["device_types"] = device_types if manufacturer is not None: - json_payload["manufacturer"] = manufacturer + params["manufacturer"] = manufacturer - res = self.client.post("/thermostats/list", json=json_payload) + res = self.client.get("/thermostats/list", params=params) return [Device.from_dict(item) for item in res["devices"]] @@ -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_daily_programs.py b/seam/routes/thermostats_daily_programs.py index cff60944..f72073e1 100644 --- a/seam/routes/thermostats_daily_programs.py +++ b/seam/routes/thermostats_daily_programs.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +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 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/thermostats_simulate.py b/seam/routes/thermostats_simulate.py index 69dec843..78352237 100644 --- a/seam/routes/thermostats_simulate.py +++ b/seam/routes/thermostats_simulate.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null class AbstractThermostatsSimulate(abc.ABC): diff --git a/seam/routes/user_identities.py b/seam/routes/user_identities.py index ab7442b2..c1cd04e0 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]: @@ -506,24 +507,24 @@ def list( :param user_identity_ids: Array of user identity IDs by which to filter the list of user identities. :returns: OK""" - json_payload: Dict[str, Any] = {} + params: Dict[str, Any] = {} if created_before is not None: - json_payload["created_before"] = created_before + params["created_before"] = created_before if credential_manager_acs_system_id is not None: - json_payload["credential_manager_acs_system_id"] = ( + params["credential_manager_acs_system_id"] = ( credential_manager_acs_system_id ) if limit is not None: - json_payload["limit"] = limit + params["limit"] = limit if page_cursor is not None: - json_payload["page_cursor"] = page_cursor + params["page_cursor"] = page_cursor if search is not None: - json_payload["search"] = search + params["search"] = search if user_identity_ids is not None: - json_payload["user_identity_ids"] = user_identity_ids + params["user_identity_ids"] = user_identity_ids - res = self.client.post("/user_identities/list", json=json_payload) + res = self.client.get("/user_identities/list", params=params) return [UserIdentity.from_dict(item) for item in res["user_identities"]] @@ -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/webhooks.py b/seam/routes/webhooks.py index 8aa22a82..bf407c41 100644 --- a/seam/routes/webhooks.py +++ b/seam/routes/webhooks.py @@ -2,6 +2,7 @@ import abc from ..client import SeamHttpClient from ..route import route_metadata +from ..null import Null from ..resources import Webhook 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/seam/url_search_params_serializer.py b/seam/url_search_params_serializer.py new file mode 100644 index 00000000..973fc3df --- /dev/null +++ b/seam/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/client_test.py b/test/client_test.py index e80bd266..360de5fc 100644 --- a/test/client_test.py +++ b/test/client_test.py @@ -4,8 +4,8 @@ def test_seam_exposes_a_client_that_can_make_requests(seam: Seam, server): _, seed = server - response = seam.client.post( - "/devices/get", json={"device_id": seed["august_device_1"]} + response = seam.client.get( + "/devices/get", params={"device_id": seed["august_device_1"]} ) assert response["device"]["workspace_id"] == seed["seed_workspace_1"] diff --git a/test/conftest.py b/test/conftest.py index 39a47693..4c867470 100755 --- a/test/conftest.py +++ b/test/conftest.py @@ -70,21 +70,16 @@ 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, + "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, } @@ -109,6 +104,15 @@ def _handle_request(self): self.end_headers() self.wfile.write(body) + # Endpoints are served over their semantic method, so record them all. + # BaseHTTPRequestHandler dispatches on these names. + # pylint: disable=invalid-name + do_GET = _handle_request + do_POST = _handle_request + do_PUT = _handle_request + do_PATCH = _handle_request + do_DELETE = _handle_request + def log_message(self, *args): pass diff --git a/test/headers_test.py b/test/headers_test.py index dfb040e0..3e6d7667 100644 --- a/test/headers_test.py +++ b/test/headers_test.py @@ -17,7 +17,9 @@ 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["method"] == "GET" + 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/http_error_test.py b/test/http_error_test.py index dd30329b..5617b7ef 100644 --- a/test/http_error_test.py +++ b/test/http_error_test.py @@ -39,8 +39,10 @@ def test_seam_http_throws_invalid_input_error(server): seam = Seam(api_key=seed["seam_apikey1_token"], endpoint=endpoint) + # A query string carries no types, so an id given as a number is read as + # its digits. Send a value that cannot be read as the declared type. with pytest.raises(SeamHttpInvalidInputError) as exc_info: - seam.devices.list(device_ids=4242) + seam.devices.list(limit="abc") err = exc_info.value assert err.status_code == 400 assert err.code == "invalid_input" diff --git a/test/null_test.py b/test/null_test.py new file mode 100644 index 00000000..cc06f35c --- /dev/null +++ b/test/null_test.py @@ -0,0 +1,130 @@ +from collections import OrderedDict + +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} + + +def sent_request(recording_server, send): + """Return the single request the given call put on the wire.""" + + with recording_server([(200, {})]) as (endpoint, requests): + send(SeamHttpClient(base_url=endpoint, auth_headers={})) + + [request] = requests + + return request + + +def test_client_sends_null_params_as_json_null(recording_server): + request = sent_request( + recording_server, + lambda client: client.patch( + "/devices/update", json={"device_id": "a", "name": NULL} + ), + ) + + assert request["body"] == {"device_id": "a", "name": None} + + +def test_client_sends_nested_null_params_as_json_null(recording_server): + request = sent_request( + recording_server, + lambda client: client.patch( + "/spaces/update", json={"customer_data": {"check_in": NULL}} + ), + ) + + assert request["body"] == {"customer_data": {"check_in": None}} + + +def test_client_passes_through_payloads_without_null_params(recording_server): + request = sent_request( + recording_server, + lambda client: client.patch( + "/devices/update", json={"device_id": "a", "name": "Front Door"} + ), + ) + + assert request["body"] == {"device_id": "a", "name": "Front Door"} + + +def test_client_sends_null_search_params_as_an_empty_value(recording_server): + request = sent_request( + recording_server, + lambda client: client.get( + "/devices/list", params={"device_id": NULL, "limit": 2} + ), + ) + + assert request["query"] == "device_id=&limit=2" + + +def test_client_omits_none_search_params(recording_server): + request = sent_request( + recording_server, + lambda client: client.get( + "/devices/list", params={"device_id": None, "limit": 2} + ), + ) + + assert request["query"] == "limit=2" diff --git a/test/serialization_test.py b/test/serialization_test.py index 40d4b0c0..1d30a2ea 100644 --- a/test/serialization_test.py +++ b/test/serialization_test.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from seam import Seam @@ -40,9 +42,9 @@ def test_serializes_array_params_when_explicitly_using_client(server): endpoint, seed = server seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint=endpoint) - response = seam.client.post( + response = seam.client.get( "/devices/list", - json={"device_ids": [seed["august_device_1"], seed["ecobee_device_1"]]}, + params={"device_ids": [seed["august_device_1"], seed["ecobee_device_1"]]}, ) device_ids = [device["device_id"] for device in response["devices"]] @@ -50,3 +52,58 @@ def test_serializes_array_params_when_explicitly_using_client(server): assert len(device_ids) == 2 assert seed["august_device_1"] in device_ids assert seed["ecobee_device_1"] in device_ids + + +def test_serializes_array_params_when_empty_and_explicitly_using_get(seam: Seam): + # The empty array is serialized to a single empty value, e.g., device_ids=, + # which the Seam API parses back to the empty array. + response = seam.client.get("/devices/list", params={"device_ids": []}) + + assert len(response["devices"]) == 0 + + +def test_serializes_array_params_when_none_and_explicitly_using_get(seam: Seam): + response = seam.client.get("/devices/list", params={"device_ids": None}) + database = seam.client.get("/_fake/database") + + assert len(response["devices"]) == len(database["devices"]) + + +def test_serializes_string_params_when_explicitly_using_get(server): + endpoint, seed = server + seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint=endpoint) + + response = seam.client.get( + "/devices/get", params={"device_id": seed["august_device_1"]} + ) + + assert response["device"]["device_id"] == seed["august_device_1"] + + +def test_serializes_number_params_when_explicitly_using_get(seam: Seam): + # A float is serialized as the Seam API expects a number, e.g., limit=2, + # never as 2.0. + response = seam.client.get("/devices/list", params={"limit": 2.0}) + + assert len(response["devices"]) == 2 + + +def test_serializes_datetime_params_when_explicitly_using_get(seam: Seam): + created_before = datetime(2999, 1, 1, tzinfo=timezone.utc) + + response = seam.client.get( + "/devices/list", params={"created_before": created_before} + ) + database = seam.client.get("/_fake/database") + + assert len(response["devices"]) == len(database["devices"]) + + +def test_serializes_params_for_a_route_using_the_semantic_method(server): + # /devices/list is a GET, so its params are serialized to the query string. + endpoint, seed = server + seam = Seam.from_api_key(seed["seam_apikey1_token"], endpoint=endpoint) + + devices = seam.devices.list(device_ids=[seed["august_device_1"]]) + + assert [device.device_id for device in devices] == [seed["august_device_1"]] diff --git a/test/timeout_test.py b/test/timeout_test.py index a7aff192..d731f01c 100644 --- a/test/timeout_test.py +++ b/test/timeout_test.py @@ -90,7 +90,7 @@ def test_per_request_timeout_overrides_the_client_timeout(recording_server): with recording_server([(200, {"devices": []})]) as (endpoint, _): seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint, timeout=30) - response = seam.client.post("/devices/list", json={}, timeout=10) + response = seam.client.get("/devices/list", params={}, timeout=10) assert response == {"devices": []} @@ -113,13 +113,17 @@ def slow_server(): class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" - # pylint: disable-next=invalid-name - def do_POST(self): + def serve_slowly(self): time.sleep(5) self.send_response(200) self.send_header("content-length", "0") self.end_headers() + # BaseHTTPRequestHandler dispatches on these names. + # pylint: disable=invalid-name + do_GET = serve_slowly + do_POST = serve_slowly + def log_message(self, *args): pass diff --git a/test/url_search_params_serializer_test.py b/test/url_search_params_serializer_test.py new file mode 100644 index 00000000..48c94e04 --- /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.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, "is_admin": True, "tags": ["cars", "planes"]}, + ) + + assert search_params.to_string() == ( + "age=27&foo=bar&is_admin=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" diff --git a/uv.lock b/uv.lock index 1d822959..646d4302 100644 --- a/uv.lock +++ b/uv.lock @@ -39,11 +39,11 @@ wheels = [ [[package]] name = "astroid" -version = "3.2.4" +version = "3.3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/53/1067e1113ecaf58312357f2cd93063674924119d80d173adc3f6f2387aa2/astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", size = 397576, upload-time = "2024-07-20T12:57:43.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" }, + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, ] [[package]] @@ -698,7 +698,7 @@ wheels = [ [[package]] name = "pylint" -version = "3.2.2" +version = "3.3.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, @@ -709,9 +709,9 @@ dependencies = [ { name = "platformdirs" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/4c/b561478a1ccb91e9b02965cb999d2281894d43e68c0bf3777d023af15f11/pylint-3.2.2.tar.gz", hash = "sha256:d068ca1dfd735fb92a07d33cb8f288adc0f6bc1287a139ca2425366f7cbe38f8", size = 1505895, upload-time = "2024-05-20T07:22:43.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/23/7a546224d2931cda031ee3cddc9e723650ad8e491d7c64efbab97e43e16d/pylint-3.2.2-py3-none-any.whl", hash = "sha256:3f8788ab20bb8383e06dd2233e50f8e08949cfd9574804564803441a4946eab4", size = 519092, upload-time = "2024-05-20T07:22:40.191Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" }, ] [[package]]