Skip to content
Open
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
89 changes: 89 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,14 @@ Contents

* `Webhooks`_

* `Omitted params and null params`_

* `Advanced Usage`_

* `Setting the endpoint`_

* `Serializing URL search params`_

* `Development and Testing`_

* `Quickstart`_
Expand Down Expand Up @@ -425,6 +429,47 @@ see the `Svix docs for more examples in specific frameworks <https://docs.svix.c
app.run(port=8080)


Omitted params and null params
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The Seam API distinguishes an omitted param from a param explicitly set to null.
In an update request, an omitted param leaves the current value unchanged,
while a null param unsets the current value.
Some endpoints also accept null as a meaningful filter value.

Python has a single absence value, so this SDK maps the two cases as follows:

- ``None``, or simply not passing the param, omits it from the request.
- ``seam.NULL`` sends the param as null.

Sending null is rarely intended and unsetting a value cannot be undone,
so ``None`` means the safe option of omitting the param
and sending null is always explicit.
Route methods accept ``NULL`` only for the params the Seam API documents as
nullable, so a type checker reports passing it to any other param as an error:

.. code-block:: python

from seam import NULL, Seam

seam = Seam()

# Unsets the device name.
seam.devices.update(device_id=device_id, name=NULL)

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

# Lists only the Access Grants which have no access_grant_key.
seam.access_grants.list(access_grant_key=NULL)

``NULL`` may be used at any depth, e.g., to clear a single key
while leaving the other keys unchanged:

.. code-block:: python

seam.spaces.update(space_id=space_id, customer_data={"check_in": NULL})

Advanced Usage
~~~~~~~~~~~~~~

Expand All @@ -436,6 +481,50 @@ e.g., testing or proxy setups.

Either pass the ``endpoint`` option to the constructor, or set the ``SEAM_ENDPOINT`` environment variable.

Serializing URL search params
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The Seam API parses URL search params as complex types.
This SDK implements the `Seam URL search params serialization standard`_,
which defines how the Seam SDKs serialize objects to URL search params.
Use it directly when building requests to the Seam API by hand:

.. code-block:: python

from seam import serialize_url_search_params

serialize_url_search_params(
{
"name": "Dax",
"age": 27,
"is_admin": True,
"tags": ["cars", "planes"],
}
)
# => 'age=27&is_admin=true&name=Dax&tags=cars&tags=planes'

Params are sorted by name, so equivalent input always produces the same query string.
Nested dicts are serialized to dot-path keys, e.g., ``{"a": {"b": 1}}`` becomes ``a.b=1``.
Params set to ``None`` are omitted,
while params set to ``seam.NULL`` are serialized to an empty value, e.g., ``a=``.
See `Omitted params and null params`_.
A param that cannot be represented raises a ``seam.UnserializableParamError``.

To merge serialized params into existing params, use ``update_url_search_params``:

.. code-block:: python

from seam import UrlSearchParams, update_url_search_params

search_params = UrlSearchParams("?foo=bar")

update_url_search_params(search_params, {"name": "Dax"})

str(search_params)
# => 'foo=bar&name=Dax'

.. _Seam URL search params serialization standard: https://github.com/seamapi/url-search-params-serializer

Development and Testing
-----------------------

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}}
1 change: 1 addition & 0 deletions codegen/layouts/route.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Optional, Any, List, Dict, Union
import abc
from ..client import SeamHttpClient
from ..null import Null
{{#if resourceClasses}}
from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}})
{{/if}}
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 @@ -9,6 +9,7 @@ export interface ClassMethodParameter {
deprecationMessage: string
position?: number | undefined
required?: boolean | undefined
isNullable?: boolean | undefined
}

export interface ClassMethod {
Expand Down
5 changes: 5 additions & 0 deletions codegen/lib/handlebars-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions codegen/lib/layouts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface MethodLayoutContext {
isDeprecated: boolean
deprecationMessage: string
required: boolean
isNullable: boolean
}>
returnPath: string[]
returnType: string
Expand Down Expand Up @@ -67,6 +68,7 @@ export const getMethodLayoutContext = (
isDeprecated: parameter.isDeprecated,
deprecationMessage: parameter.deprecationMessage,
required: parameter.required ?? false,
isNullable: parameter.isNullable ?? false,
})),
returnPath: method.returnPath,
returnType: method.returnResource,
Expand Down
1 change: 1 addition & 0 deletions codegen/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const routes = (
deprecationMessage: parameter.deprecationMessage,
position: parameter.name === idParameterName ? 0 : undefined,
required: parameter.isRequired,
isNullable: parameter.isNullable,
})),
...resolveResponse(response),
})
Expand Down
18 changes: 9 additions & 9 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
}
},
"devDependencies": {
"@seamapi/blueprint": "^1.1.0",
"@seamapi/blueprint": "^1.4.0",
"@seamapi/fake-seam-connect": "1.86.0",
"@seamapi/smith": "^1.1.0",
"@seamapi/types": "1.983.0",
"@seamapi/types": "1.984.0",
"change-case": "^5.4.4",
"prettier": "^3.2.5"
}
Expand Down
7 changes: 7 additions & 0 deletions seam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@
)
from .seam_webhook import SeamWebhook
from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError
from .null import NULL, Null
from .utils.url_search_params_serializer import (
UnserializableParamError,
UrlSearchParams,
serialize_url_search_params,
update_url_search_params,
)
7 changes: 7 additions & 0 deletions seam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
SeamHttpInvalidInputError,
SeamHttpUnauthorizedError,
)
from .null import replace_null

SDK_HEADERS = {
"seam-sdk-name": "seamapi/python",
Expand Down Expand Up @@ -59,6 +60,12 @@ def __init__(

def request(self, method, url, *args, **kwargs):
url = urljoin(self.base_url, url)

# Route methods omit params set to None, so any remaining NULL sentinel
# is an explicit null and becomes None for JSON serialization.
if "json" in kwargs:
kwargs["json"] = replace_null(kwargs["json"])

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

return self._handle_response(response)
Expand Down
95 changes: 95 additions & 0 deletions seam/null.py
Original file line number Diff line number Diff line change
@@ -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
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.

1 change: 1 addition & 0 deletions seam/routes/access_codes_simulate.py

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

Loading
Loading