Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Contents

* `Action Attempts`_

* `Setting a Param to Null`_

* `Pagination`_

* `Manually fetch pages with the next_page_cursor`_
Expand Down Expand Up @@ -280,6 +282,56 @@ For example:
except SeamActionAttemptTimeoutError as e:
print("Door took too long to unlock")

Setting a Param to Null
~~~~~~~~~~~~~~~~~~~~~~~

The Seam API tells an omitted param apart from one explicitly set to null.
In an update request, an omitted param leaves the current value unchanged,
while a null param unsets it.

Python has a single nil value `None` which represents an undefined parameter. This SDK provides an explicit null value to send in requests.
A param set to ``None`` is omitted, and a param set to ``NULL`` is sent as `null`:

.. code-block:: python

from seam import NULL, Seam

seam = Seam()

# Leaves the name unchanged.
seam.devices.update(device_id="your-device-id", name=None)

# Unsets the name.
seam.devices.update(device_id="your-device-id", name=NULL)

Because unsetting a value cannot be undone, ``None`` means the safe option of
omitting the param, and sending null is always explicit.
This is why a param is never sent as null by default,
even though ``None`` is the natural way to spell null in Python.

``NULL`` behaves the same way in a request body and in a URL search param.
Its type is exported as ``Null`` for annotating your own code:

.. code-block:: python

from typing import Optional, Union

from seam import NULL, Null

name: Optional[Union[str, Null]] = NULL

Only params the Seam API documents as nullable accept ``NULL``.
The generated method signatures say which ones those are,
so a type checker rejects ``NULL`` anywhere else:

.. code-block:: python

# name is nullable, so it may be unset.
seam.devices.update(device_id="your-device-id", name=NULL)

# is_managed is not, so this fails the type check.
seam.devices.update(device_id="your-device-id", is_managed=NULL)

Pagination
~~~~~~~~~~

Expand Down Expand Up @@ -562,8 +614,9 @@ A client may percent-encode a few characters differently than
``URLSearchParams`` does, e.g. httpx escapes ``*`` and unescapes ``~``,
which the Seam API reads as the same params either way.

A param set to ``None`` is omitted, while a param set to ``seam.NULL``
is serialized to an empty value, which the Seam API reads as null.
A param set to ``None`` is omitted, while a param set to ``NULL``
is serialized to an empty value, which the Seam API reads as null,
as described in `Setting a Param to Null`_.
A param that cannot be represented raises a ``seam.UnserializableParamError``.

The Seam API parses these params with the corresponding `parser`_.
Expand Down
2 changes: 1 addition & 1 deletion codegen/layouts/partials/method-signature.hbs
Original file line number Diff line number Diff line change
@@ -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}}
{{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}}
3 changes: 3 additions & 0 deletions codegen/layouts/route.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ from typing import Optional, Any, List, Dict, Union
import abc
from ..client import SeamHttpClient
from ..route import route_metadata
{{#if importNull}}
from ..null import Null
{{/if}}
{{#if resourceClasses}}
from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}})
{{/if}}
Expand Down
1 change: 1 addition & 0 deletions codegen/lib/class-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export interface ClassMethodParameter {
name: string
type: string
isNullable: boolean
description: string
isDeprecated: boolean
deprecationMessage: string
Expand Down
6 changes: 6 additions & 0 deletions codegen/lib/handlebars-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export const indent = (value: string, spaces: number): string =>
export const pythonIdentifier = (name: string): string =>
PYTHON_KEYWORDS.has(name) ? `${name}_` : name

// A param the API documents as nullable may be set to the NULL sentinel, which
// the client serializes to null. Params that are merely optional may not: they
// are omitted by passing None, and sending null would unset a value instead.
export const nullableType = (type: string, isNullable: boolean): string =>
isNullable ? `Union[${type}, Null]` : type

export const isListType = (type: string): boolean => type.startsWith('List[')

export const listItemType = (type: string): string => type.slice(5, -1)
8 changes: 8 additions & 0 deletions codegen/lib/layouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface MethodLayoutContext {
params: Array<{
name: string
type: string
isNullable: boolean
description: string
isDeprecated: boolean
deprecationMessage: string
Expand Down Expand Up @@ -53,6 +54,7 @@ export interface RouteLayoutContext {
module: string
}>
importResolveActionAttempt: boolean
importNull: boolean
methods: MethodLayoutContext[]
}

Expand Down Expand Up @@ -83,6 +85,7 @@ export const getMethodLayoutContext = (
params: sortClassMethodParameters(method.parameters).map((parameter) => ({
name: parameter.name,
type: parameter.type,
isNullable: parameter.isNullable,
description: parameter.description,
isDeprecated: parameter.isDeprecated,
deprecationMessage: parameter.deprecationMessage,
Expand All @@ -108,6 +111,10 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
const abstractClassName = `Abstract${cls.name}`
const methods = cls.methods.map(getMethodLayoutContext)

const importNull = methods.some(({ params }) =>
params.some(({ isNullable }) => isNullable),
)

return {
className: cls.name,
abstractClassName,
Expand All @@ -131,6 +138,7 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
module: `${cls.namespace}_${identifier.namespace}`,
})),
importResolveActionAttempt,
importNull,
methods,
}
}
1 change: 1 addition & 0 deletions codegen/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const routes = (
parameters: endpoint.request.parameters.map((parameter) => ({
name: parameter.name,
type: mapParameterToPythonType(parameter),
isNullable: parameter.isNullable,
description: parameter.description,
isDeprecated: parameter.isDeprecated,
deprecationMessage: parameter.deprecationMessage,
Expand Down
18 changes: 18 additions & 0 deletions seam/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Mapping
from typing import Any, Dict, Optional
from importlib.metadata import version
import abc
Expand All @@ -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",
Expand Down Expand Up @@ -102,6 +105,12 @@ def delete(self, url, json=None, **kwargs) -> Any:
return self.request("DELETE", url, json=json, **kwargs)

def request(self, method, url, *args, **kwargs) -> Any:
if isinstance(kwargs.get("params"), Mapping):
url = with_search_params(url, kwargs.pop("params"))

if "json" in kwargs:
kwargs["json"] = replace_null(kwargs["json"])

response = super().request(method, url, *args, **kwargs)

return self._handle_response(response)
Expand Down Expand Up @@ -142,6 +151,15 @@ def _handle_error_response(self, response: Response):
raise SeamHttpApiError(error_details, status_code, request_id)


def with_search_params(url: Any, params: Mapping[str, Any]) -> Any:
query = serialize_url_search_params(params)

if not query:
return url

return httpx.URL(url, query=query.encode())


def is_api_error_response(response: Response) -> bool:
try:
content_type = response.headers.get("content-type", "")
Expand Down
25 changes: 25 additions & 0 deletions seam/null.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Sending null is explicit and always spelled :data:`NULL`.
"""

from collections.abc import Mapping, Sequence
from typing import Any


Expand Down Expand Up @@ -60,3 +61,27 @@ def is_null(value: Any) -> bool:
:returns: Whether the value is the ``NULL`` sentinel"""

return isinstance(value, Null)


def replace_null(value: Any) -> Any:
"""Returns a copy of a value with every :data:`NULL` sentinel replaced by ``None``.

The sentinel only distinguishes an explicit null from an omitted param
within this SDK. Once a request body is being serialized, the param is
known to be present, so the sentinel becomes the null that JSON has.

:param value: The value to copy
:type value: Any

:returns: The value with each ``NULL`` sentinel replaced by ``None``"""

if is_null(value):
return None

if isinstance(value, Mapping):
return {key: replace_null(item) for key, item in value.items()}

if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return [replace_null(item) for item in value]

return value
5 changes: 3 additions & 2 deletions seam/routes/access_codes.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions seam/routes/access_codes_unmanaged.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 13 additions & 12 deletions seam/routes/access_grants.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading