|
| 1 | +# Migrating from seam v2 to v3 |
| 2 | + |
| 3 | +This guide covers upgrading from `seam` v2.x to v3 of the [Seam Python SDK](https://github.com/seamapi/python). |
| 4 | + |
| 5 | +Version 3 replaces the underlying HTTP library, adds client-side validation and explicit null support, and regenerates the API surface against the latest Seam API. Most application code — authentication, method names, resource models, action attempts, and pagination — works unchanged. The breaking changes are concentrated in client configuration and error handling. |
| 6 | + |
| 7 | +## Installation |
| 8 | + |
| 9 | +While v3 is in prerelease, install it explicitly: |
| 10 | + |
| 11 | +```sh |
| 12 | +pip install --pre seam |
| 13 | +# or pin a specific beta |
| 14 | +pip install 'seam==3.0.0b6' |
| 15 | +``` |
| 16 | + |
| 17 | +## Summary of breaking changes |
| 18 | + |
| 19 | +| Change | Affects you if... | |
| 20 | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | |
| 21 | +| [Python 3.11+ required](#python-311-or-later-is-required) | You run Python 3.10 | |
| 22 | +| [httpx replaces niquests](#httpx-replaces-niquests) | You pass `niquests_options`, catch `niquests` exceptions, or touch `seam.client` directly | |
| 23 | +| [`retries` takes an `httpx_retries.Retry`](#retry-configuration-uses-httpx-retries) | You pass a custom `retries` option | |
| 24 | +| [Endpoints validate parameters client-side](#client-side-parameter-validation) | You call endpoints with no parameters, or rely on the server's 400 response | |
| 25 | +| [`lts_version` removed](#lts_version-is-removed) | You read `Seam.lts_version` or the `seam-lts-version` header | |
| 26 | +| [Preferred HTTP methods and URL search params](#endpoints-use-preferred-http-methods) | You inspect traffic in a proxy, mock server, or firewall rules | |
| 27 | + |
| 28 | +## Python 3.11 or later is required |
| 29 | + |
| 30 | +Version 2 supported Python 3.10. Version 3 requires Python >= 3.11 and is tested on Python 3.11 through 3.14. |
| 31 | + |
| 32 | +## httpx replaces niquests |
| 33 | + |
| 34 | +The SDK's HTTP layer is now [httpx](https://www.python-httpx.org/) instead of [niquests](https://niquests.readthedocs.io/). This surfaces in three places. |
| 35 | + |
| 36 | +### The `niquests_options` option is renamed to `httpx_options` |
| 37 | + |
| 38 | +Options are now passed to the underlying `httpx.Client`, so both the option name and its contents change. For example, connection pool limits: |
| 39 | + |
| 40 | +```python |
| 41 | +# v2 |
| 42 | +seam = Seam( |
| 43 | + api_key="your-api-key", |
| 44 | + niquests_options={"pool_connections": 20, "pool_maxsize": 25}, |
| 45 | +) |
| 46 | + |
| 47 | +# v3 |
| 48 | +from httpx import Limits |
| 49 | + |
| 50 | +seam = Seam( |
| 51 | + api_key="your-api-key", |
| 52 | + httpx_options={ |
| 53 | + "limits": Limits(max_connections=25, max_keepalive_connections=20), |
| 54 | + }, |
| 55 | +) |
| 56 | +``` |
| 57 | + |
| 58 | +This applies to `Seam()`, `Seam.from_api_key()`, `Seam.from_personal_access_token()`, and `SeamWithoutWorkspace`. |
| 59 | + |
| 60 | +### Transport-level exceptions are httpx exceptions |
| 61 | + |
| 62 | +Requests that time out now raise `httpx.TimeoutException` instead of `niquests.exceptions.Timeout`, and connection failures raise httpx transport errors (`httpx.ConnectError`, etc.) instead of niquests/urllib3 ones. |
| 63 | + |
| 64 | +```python |
| 65 | +# v2 |
| 66 | +import niquests |
| 67 | + |
| 68 | +try: |
| 69 | + seam.devices.list() |
| 70 | +except niquests.exceptions.Timeout: |
| 71 | + ... |
| 72 | + |
| 73 | +# v3 |
| 74 | +import httpx |
| 75 | + |
| 76 | +try: |
| 77 | + seam.devices.list() |
| 78 | +except httpx.TimeoutException: |
| 79 | + ... |
| 80 | +``` |
| 81 | + |
| 82 | +Seam API errors are unchanged: `SeamHttpApiError`, `SeamHttpInvalidInputError`, and `SeamHttpUnauthorizedError` are raised exactly as in v2. |
| 83 | + |
| 84 | +### `seam.client` is an httpx.Client |
| 85 | + |
| 86 | +If you access the client directly, it is now an `httpx.Client` subclass rather than a niquests `Session`. Notably, response hooks are registered via `event_hooks` instead of `hooks`. |
| 87 | + |
| 88 | +## Retry configuration uses httpx-retries |
| 89 | + |
| 90 | +The `retries` option now takes a `Retry` object from [httpx-retries](https://will-ockmore.github.io/httpx-retries/) instead of `urllib3.util.retry.Retry`. The class is re-exported from `seam` for convenience: |
| 91 | + |
| 92 | +```python |
| 93 | +# v2 |
| 94 | +from urllib3.util.retry import Retry |
| 95 | + |
| 96 | +seam = Seam(api_key="your-api-key", retries=Retry(total=3)) |
| 97 | + |
| 98 | +# v3 |
| 99 | +from seam import Seam, Retry |
| 100 | + |
| 101 | +seam = Seam( |
| 102 | + api_key="your-api-key", |
| 103 | + retries=Retry(total=3, backoff_factor=0.5, status_forcelist=[503]), |
| 104 | +) |
| 105 | +``` |
| 106 | + |
| 107 | +The default retry policy is now explicit and documented. Out of the box, the SDK makes up to three attempts: the initial request and two retries. Retries are limited to `GET`, `HEAD`, `OPTIONS`, `PUT`, and `DELETE` requests that fail because of a transport error, timeout, HTTP 429 response, or HTTP 5xx response. `POST` and `PATCH` requests are never retried. Retries use exponential backoff with jitter, and a `Retry-After` header is honored instead of the calculated backoff. |
| 108 | + |
| 109 | +In v2, the default was urllib3's implicit `Retry()` (connection-level retries only, with no retries on HTTP status codes such as 429 or 5xx). If you depended on requests never being retried on 429/5xx, pass an explicit policy, e.g. `retries=Retry(total=0)`. |
| 110 | + |
| 111 | +## Client-side parameter validation |
| 112 | + |
| 113 | +Endpoints that require at least one parameter now raise `ValueError` locally instead of sending the request and letting the server reject it: |
| 114 | + |
| 115 | +```python |
| 116 | +# v2: raises SeamHttpInvalidInputError after a round trip to the server |
| 117 | +# v3: raises ValueError("At least one parameter is required for /locks/get") |
| 118 | +seam.locks.get() |
| 119 | +``` |
| 120 | + |
| 121 | +`create_paginator` is validated the same way. It raises `ValueError` when given a non-paginated endpoint, and when given an endpoint that requires parameters without any: |
| 122 | + |
| 123 | +```python |
| 124 | +# v3: raises ValueError - /devices/get is not paginated |
| 125 | +seam.create_paginator(seam.devices.get) |
| 126 | +``` |
| 127 | + |
| 128 | +If you catch `SeamHttpInvalidInputError` around calls that could be sent with no parameters, also handle `ValueError` (or fix the call site). |
| 129 | + |
| 130 | +## `lts_version` is removed |
| 131 | + |
| 132 | +The `Seam.lts_version` / `SeamWithoutWorkspace.lts_version` attribute and the `seam-lts-version` request header no longer exist. There is no replacement; use the package version instead: |
| 133 | + |
| 134 | +```python |
| 135 | +from importlib.metadata import version |
| 136 | + |
| 137 | +version("seam") |
| 138 | +``` |
| 139 | + |
| 140 | +## Endpoints use preferred HTTP methods |
| 141 | + |
| 142 | +In v2, every endpoint was called with `POST` and a JSON body. In v3, endpoints use the HTTP method the Seam API prefers: |
| 143 | + |
| 144 | +- Read endpoints (`get`, `list`, and friends) use `GET`, with parameters sent as URL search params serialized per [Seam's URL search params standard](https://github.com/seamapi/url-search-params-serializer). |
| 145 | +- Update endpoints use `PATCH` or `PUT`. |
| 146 | +- Delete endpoints use `DELETE`. |
| 147 | +- Create and action endpoints (`create`, `lock_door`, etc.) remain `POST`. |
| 148 | + |
| 149 | +Method signatures, arguments, and return values are unchanged — this only matters if something outside your code observes the HTTP traffic: proxy or firewall rules that allowlist methods, request logging, or test mocks registered against `POST` routes. Note the interaction with the new retry defaults: because reads are now `GET`, they are retried by default, which they were not in v2 (as `POST`). |
| 150 | + |
| 151 | +If you call the Seam API with your own HTTP client, the serializer used for `GET` params is exported: |
| 152 | + |
| 153 | +```python |
| 154 | +import httpx |
| 155 | +from seam import serialize_url_search_params |
| 156 | + |
| 157 | +httpx.get( |
| 158 | + "https://connect.getseam.com/devices/list", |
| 159 | + params=serialize_url_search_params({"device_ids": ["device1", "device2"]}), |
| 160 | + headers={"Authorization": "Bearer your-api-key"}, |
| 161 | +) |
| 162 | +``` |
| 163 | + |
| 164 | +## New in v3 |
| 165 | + |
| 166 | +These are additions, not breaking changes, but they are worth adopting while you migrate. |
| 167 | + |
| 168 | +### Explicit null with `NULL` |
| 169 | + |
| 170 | +The Seam API distinguishes an omitted parameter from one explicitly set to null: in an update request, an omitted parameter leaves the current value unchanged, while a null parameter unsets it. Version 2 had no way to send null — `None` always meant "omit". Version 3 keeps that behavior for `None` and adds a `NULL` sentinel for sending an explicit null: |
| 171 | + |
| 172 | +```python |
| 173 | +from seam import NULL, Seam |
| 174 | + |
| 175 | +seam = Seam() |
| 176 | + |
| 177 | +# Leaves the name unchanged (same as v2). |
| 178 | +seam.devices.update(device_id="your-device-id", name=None) |
| 179 | + |
| 180 | +# Unsets the name (new in v3). |
| 181 | +seam.devices.update(device_id="your-device-id", name=NULL) |
| 182 | +``` |
| 183 | + |
| 184 | +Only parameters the Seam API documents as nullable are typed to accept `NULL`, so a type checker will flag misuse. The sentinel's type is exported as `Null` for annotating your own code. |
| 185 | + |
| 186 | +### New exports |
| 187 | + |
| 188 | +`seam` now exports `NULL`, `Null`, `Retry` (from httpx-retries), `UrlSearchParams`, `serialize_url_search_params`, `update_url_search_params`, and `UnserializableParamError`, alongside everything exported in v2. |
| 189 | + |
| 190 | +## Migration checklist |
| 191 | + |
| 192 | +1. Upgrade your runtime to Python 3.11 or later. |
| 193 | +2. Update the dependency: `seam>=3,<4` (or a pinned `3.0.0bN` while in prerelease). |
| 194 | +3. Rename `niquests_options` to `httpx_options` and translate its contents to `httpx.Client` options. |
| 195 | +4. Replace `urllib3.util.retry.Retry` with `seam.Retry` (httpx-retries) in any `retries` argument, and review the new default retry policy. |
| 196 | +5. Replace handling of `niquests`/`urllib3` exceptions with the `httpx` equivalents (`httpx.TimeoutException`, `httpx.ConnectError`, ...). Seam error classes are unchanged. |
| 197 | +6. Remove any use of `lts_version` or the `seam-lts-version` header. |
| 198 | +7. Handle `ValueError` from endpoints and `create_paginator` where calls might carry no parameters. |
| 199 | +8. If proxies, firewalls, or test mocks assume all requests are `POST`, update them for `GET`/`PATCH`/`PUT`/`DELETE`. |
| 200 | +9. Optionally, adopt `NULL` where you need to unset nullable values. |
| 201 | + |
| 202 | +# Migrating from seam v1 to v2 |
| 203 | + |
| 204 | +If you are still on v1.x, migrate to v2 first (or apply both guides together). Version 2 is a much smaller upgrade than v3: client configuration, authentication, endpoint methods, and error handling are all unchanged. The breaking changes are in resource objects and one class rename. |
| 205 | + |
| 206 | +## Summary of breaking changes |
| 207 | + |
| 208 | +| Change | Affects you if... | |
| 209 | +| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | |
| 210 | +| [Nested resource properties are typed objects](#nested-resource-properties-are-typed-objects) | You treat nested properties as dicts, or rely on unknown-attribute reads | |
| 211 | +| [`SeamMultiWorkspace` renamed to `SeamWithoutWorkspace`](#seammultiworkspace-is-renamed-to-seamwithoutworkspace) | You use `SeamMultiWorkspace` | |
| 212 | + |
| 213 | +## Nested resource properties are typed objects |
| 214 | + |
| 215 | +In v1, nested properties on resources — for example `device.properties` or `action_attempt.result` — were dict subclasses with attribute access layered on top. In v2, they hydrate as typed dataclasses scoped to their parent resource, such as `Device.Properties` and `ActionAttempt.Result`, so IDEs and type checkers can see their fields. |
| 216 | + |
| 217 | +Attribute access and dictionary-style _reads_ keep working: |
| 218 | + |
| 219 | +```python |
| 220 | +device = seam.devices.get(device_id="your-device-id") |
| 221 | + |
| 222 | +device.properties.locked # still works |
| 223 | +device.properties["locked"] # still works |
| 224 | +device.properties.get("online") # still works |
| 225 | +"locked" in device.properties # still works |
| 226 | +``` |
| 227 | + |
| 228 | +What breaks: |
| 229 | + |
| 230 | +- **They are no longer dicts.** `isinstance(device.properties, dict)` is now `False`, and mutation (`device.properties["x"] = ...`) and dict-only methods such as `.items()` and `.values()` are gone. Iterate over `.keys()` and index instead. |
| 231 | +- **Typoed attributes raise `AttributeError`.** In v1, reading an unknown attribute silently returned (and inserted) an empty mapping, so typos went unnoticed and were truthy-checked as empty dicts. In v2 they fail loudly — code that probed for optional fields via bare attribute access should use `.get("field")` or `hasattr`. |
| 232 | +- **Undocumented nested fields are stripped.** API fields not (yet) in the SDK's generated types are dropped during hydration instead of being passed through. If you depend on a field the SDK does not model, upgrade the SDK to a version that includes it. |
| 233 | + |
| 234 | +Free-form record properties, such as `custom_metadata`, remain plain mappings and are not affected. |
| 235 | + |
| 236 | +## `SeamMultiWorkspace` is renamed to `SeamWithoutWorkspace` |
| 237 | + |
| 238 | +The client for personal access tokens without a workspace is renamed; there is no compatibility alias. Its constructor, options, and methods are otherwise identical: |
| 239 | + |
| 240 | +```python |
| 241 | +# v1 |
| 242 | +from seam import SeamMultiWorkspace |
| 243 | + |
| 244 | +seam = SeamMultiWorkspace(personal_access_token="your-personal-access-token") |
| 245 | + |
| 246 | +# v2 |
| 247 | +from seam import SeamWithoutWorkspace |
| 248 | + |
| 249 | +seam = SeamWithoutWorkspace(personal_access_token="your-personal-access-token") |
| 250 | +``` |
| 251 | + |
| 252 | +The abstract base class is likewise renamed from `AbstractSeamMultiWorkspace` to `AbstractSeamWithoutWorkspace`. |
| 253 | + |
| 254 | +## New in v2 |
| 255 | + |
| 256 | +Version 2.2 also reads authentication from the environment: `SEAM_PERSONAL_ACCESS_TOKEN` and `SEAM_WORKSPACE_ID` are picked up when no explicit credentials are passed (`SEAM_API_KEY` was already supported in v1). Setting both `SEAM_API_KEY` and `SEAM_PERSONAL_ACCESS_TOKEN` is an error. |
| 257 | + |
| 258 | +## Migration checklist |
| 259 | + |
| 260 | +1. Update the dependency: `seam>=2,<3`. |
| 261 | +2. Rename `SeamMultiWorkspace` to `SeamWithoutWorkspace` (and `AbstractSeamMultiWorkspace` to `AbstractSeamWithoutWorkspace`). |
| 262 | +3. Replace dict-style mutation and `.items()`/`.values()`/`isinstance(..., dict)` usage on nested resource properties with attribute access or `.keys()` iteration. |
| 263 | +4. Replace bare attribute probes for optional nested fields with `.get()` or `hasattr` — unknown attributes now raise `AttributeError`. |
0 commit comments