Skip to content

Commit 82167ec

Browse files
committed
Add async support with AsyncSeam and AsyncSeamWithoutWorkspace
Add an async variant of every client entrypoint, mirroring the sync API one-to-one, backed by httpx.AsyncClient: - AsyncSeamHttpClient wraps httpx.AsyncClient with the same retry transport, header, and error handling as SeamHttpClient, with the shared response handling extracted into SeamHttpResponseHandler. - Generate Async* and AbstractAsync* route classes alongside the sync classes from the same templates, plus AsyncRoutes in the routes index. - Add resolve_action_attempt_async polling with asyncio.sleep, and AsyncSeamPaginator with async response hooks and an async flatten generator. - Add AsyncSeam and AsyncSeamWithoutWorkspace with async context manager support and close(), and give the sync clients close() and context manager support for symmetry. - Test the async client against the fake Seam Connect server and the recording server using pytest-asyncio, covering auth, action attempt waiting, pagination, retries, errors, and concurrent requests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TszCfraSbGaYQ9TFaD5jjX
1 parent 63dc208 commit 82167ec

62 files changed

Lines changed: 15239 additions & 239 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ Contents
5959

6060
* `Return all resources across all pages as a list`_
6161

62+
* `Asynchronous Usage`_
63+
6264
* `Requests without a Workspace in Scope`_
6365

6466
* `Personal Access Token without a Workspace`_
@@ -428,6 +430,62 @@ Return all resources across all pages as a list
428430
429431
all_devices = paginator.flatten_to_list()
430432
433+
Asynchronous Usage
434+
~~~~~~~~~~~~~~~~~~
435+
436+
Use ``AsyncSeam`` inside an event loop, e.g., with asyncio-based
437+
frameworks such as FastAPI.
438+
It accepts the same options and exposes the same API methods as ``Seam``,
439+
except every API method is a coroutine that must be awaited.
440+
441+
Use the client as an async context manager,
442+
or call ``await seam.close()`` when done,
443+
to release the underlying connection pool.
444+
445+
.. code-block:: python
446+
447+
import asyncio
448+
449+
from seam import AsyncSeam
450+
451+
452+
async def main():
453+
async with AsyncSeam() as seam:
454+
devices = await seam.devices.list()
455+
456+
lock = await seam.locks.get(name="Front Door")
457+
await seam.locks.unlock_door(device_id=lock.device_id)
458+
459+
460+
asyncio.run(main())
461+
462+
Requests run concurrently with the standard asyncio tools.
463+
464+
.. code-block:: python
465+
466+
async def list_resources(seam):
467+
return await asyncio.gather(
468+
seam.devices.list(),
469+
seam.connected_accounts.list(),
470+
)
471+
472+
Paginate with the same ``create_paginator`` helper.
473+
The paginator methods are coroutines,
474+
and ``flatten`` returns an async generator.
475+
476+
.. code-block:: python
477+
478+
async def list_connected_accounts(seam):
479+
paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20})
480+
481+
connected_accounts, pagination = await paginator.first_page()
482+
483+
async for account in paginator.flatten():
484+
print(account.account_type_display_name)
485+
486+
The ``AsyncSeamWithoutWorkspace`` client is the equivalent async variant of
487+
``SeamWithoutWorkspace``.
488+
431489
Requests without a Workspace in Scope
432490
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
433491

codegen/layouts/partials/abstract-route-class.hbs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class {{className}}(abc.ABC):
1616
{{#each methods}}
1717

1818
@abc.abstractmethod
19-
def {{> method-signature}}:
19+
{{#if ../isAsync}}async {{/if}}def {{> method-signature}}:
2020
"""{{> method-docstring}}"""
2121
raise NotImplementedError()
2222
{{/each}}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@dataclass
2-
class AbstractRoutes(abc.ABC):
3-
{{#each routesNamespaces}}
2+
class {{className}}(abc.ABC):
3+
{{#each namespaces}}
44
{{namespace}}: {{abstractClassName}}
55
{{/each}}

codegen/layouts/partials/route-method.hbs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@route_metadata(path="{{path}}", has_required_parameters={{#if hasRequiredParameters}}True{{else}}False{{/if}}, has_pagination={{#if hasPagination}}True{{else}}False{{/if}})
2-
def {{> method-signature}}:
2+
{{#if isAsync}}async {{/if}}def {{> method-signature}}:
33
"""{{> method-docstring}}"""
44
{{payloadVar}}: Dict[str, Any] = {}
55

@@ -13,7 +13,7 @@
1313
raise ValueError("At least one parameter is required for {{path}}")
1414
{{/if}}
1515

16-
{{#unless (eq returnType "None")}}res = {{/unless}}self.client.{{httpVerb}}("{{path}}", {{payloadArg}}={{payloadVar}})
16+
{{#unless (eq returnType "None")}}res = {{/unless}}{{#if isAsync}}await {{/if}}self.client.{{httpVerb}}("{{path}}", {{payloadArg}}={{payloadVar}})
1717
{{#if (eq returnType "ActionAttempt")}}
1818

1919
wait_for_action_attempt = (
@@ -22,7 +22,7 @@
2222
else wait_for_action_attempt
2323
)
2424

25-
return resolve_action_attempt(
25+
return {{#if isAsync}}await resolve_action_attempt_async{{else}}resolve_action_attempt{{/if}}(
2626
client=self.client,
2727
action_attempt=ActionAttempt.from_dict(res["action_attempt"]),
2828
wait_for_action_attempt=wait_for_action_attempt

codegen/layouts/route.hbs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Optional, Any, List, Dict, Literal, Union
22
import abc
3-
from ..client import SeamHttpClient
3+
from ..client import SeamHttpClient, AsyncSeamHttpClient
44
from ..route import route_metadata
55
{{#if importNull}}
66
from ..null import Null
@@ -9,16 +9,19 @@ from ..null import Null
99
from ..resources import ({{#each resourceClasses}}{{this}}{{#unless @last}},{{/unless}}{{/each}})
1010
{{/if}}
1111
{{#each childClasses}}
12-
from .{{module}} import {{abstractClassName}}, {{className}}
12+
from .{{module}} import {{abstractClassName}}, {{className}}, {{asyncAbstractClassName}}, {{asyncClassName}}
1313
{{/each}}
1414
{{#if importResolveActionAttempt}}
15-
from ..modules.action_attempts import resolve_action_attempt
15+
from ..modules.action_attempts import resolve_action_attempt, resolve_action_attempt_async
1616
{{/if}}
1717

1818

1919
{{> abstract-route-class abstractClass}}
2020

2121

22+
{{> abstract-route-class asyncAbstractClass}}
23+
24+
2225
class {{className}}({{abstractClassName}}):
2326
{{#if isDeprecated}}
2427
""".. deprecated::
@@ -40,3 +43,26 @@ class {{className}}({{abstractClassName}}):
4043

4144
{{> route-method}}
4245
{{/each}}
46+
47+
48+
class {{asyncClassName}}({{asyncAbstractClassName}}):
49+
{{#if isDeprecated}}
50+
""".. deprecated::
51+
This route is deprecated."""
52+
{{/if}}
53+
def __init__(self, client: AsyncSeamHttpClient, defaults: Dict[str, Any]):
54+
self.client = client
55+
self.defaults = defaults
56+
{{#each childClasses}}
57+
self._{{namespace}} = {{asyncClassName}}(client=client, defaults=defaults)
58+
{{/each}}
59+
{{#each childClasses}}
60+
61+
@property
62+
def {{namespace}}(self) -> {{asyncClassName}}:
63+
return self._{{namespace}}
64+
{{/each}}
65+
{{#each methods}}
66+
67+
{{> route-method isAsync=true}}
68+
{{/each}}

codegen/layouts/routes-index.hbs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
from typing import Any, Dict
22
import abc
33
from dataclasses import dataclass
4-
from ..client import SeamHttpClient
4+
from ..client import SeamHttpClient, AsyncSeamHttpClient
55
{{#each namespaces}}
6-
from .{{namespace}} import {{abstractClassName}}, {{className}}
6+
from .{{namespace}} import {{abstractClassName}}, {{className}}, {{asyncAbstractClassName}}, {{asyncClassName}}
77
{{/each}}
88

99

10-
{{> abstract-routes}}
10+
{{> abstract-routes abstractRoutes}}
11+
12+
13+
{{> abstract-routes asyncAbstractRoutes}}
1114

1215

1316
class Routes(AbstractRoutes):
1417
def __init__(self, client: SeamHttpClient, defaults: Dict[str, Any]):
1518
{{#each namespaces}}
1619
self.{{namespace}} = {{className}}(client=client, defaults=defaults)
1720
{{/each}}
21+
22+
23+
class AsyncRoutes(AbstractAsyncRoutes):
24+
def __init__(self, client: AsyncSeamHttpClient, defaults: Dict[str, Any]):
25+
{{#each namespaces}}
26+
self.{{namespace}} = {{asyncClassName}}(client=client, defaults=defaults)
27+
{{/each}}

codegen/lib/layouts/route.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface MethodLayoutContext {
3535

3636
export interface AbstractClassLayoutContext {
3737
className: string
38+
isAsync: boolean
3839
isDeprecated: boolean
3940
showPass: boolean
4041
childProperties: Array<{ namespace: string; abstractClassName: string }>
@@ -44,13 +45,18 @@ export interface AbstractClassLayoutContext {
4445
export interface RouteLayoutContext {
4546
className: string
4647
abstractClassName: string
48+
asyncClassName: string
49+
asyncAbstractClassName: string
4750
isDeprecated: boolean
4851
abstractClass: AbstractClassLayoutContext
52+
asyncAbstractClass: AbstractClassLayoutContext
4953
resourceClasses: string[]
5054
childClasses: Array<{
5155
namespace: string
5256
className: string
5357
abstractClassName: string
58+
asyncClassName: string
59+
asyncAbstractClassName: string
5460
module: string
5561
}>
5662
importResolveActionAttempt: boolean
@@ -109,32 +115,52 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => {
109115
)
110116

111117
const abstractClassName = `Abstract${cls.name}`
118+
const asyncClassName = `Async${cls.name}`
119+
const asyncAbstractClassName = `AbstractAsync${cls.name}`
112120
const methods = cls.methods.map(getMethodLayoutContext)
113121

114122
const importNull = methods.some(({ params }) =>
115123
params.some(({ isNullable }) => isNullable),
116124
)
117125

126+
const showPass =
127+
cls.methods.length === 0 && cls.childClassIdentifiers.length === 0
128+
118129
return {
119130
className: cls.name,
120131
abstractClassName,
132+
asyncClassName,
133+
asyncAbstractClassName,
121134
isDeprecated: cls.isDeprecated,
122135
abstractClass: {
123136
className: abstractClassName,
137+
isAsync: false,
124138
isDeprecated: cls.isDeprecated,
125-
showPass:
126-
cls.methods.length === 0 && cls.childClassIdentifiers.length === 0,
139+
showPass,
127140
childProperties: cls.childClassIdentifiers.map((identifier) => ({
128141
namespace: identifier.namespace,
129142
abstractClassName: `Abstract${identifier.className}`,
130143
})),
131144
methods,
132145
},
146+
asyncAbstractClass: {
147+
className: asyncAbstractClassName,
148+
isAsync: true,
149+
isDeprecated: cls.isDeprecated,
150+
showPass,
151+
childProperties: cls.childClassIdentifiers.map((identifier) => ({
152+
namespace: identifier.namespace,
153+
abstractClassName: `AbstractAsync${identifier.className}`,
154+
})),
155+
methods,
156+
},
133157
resourceClasses,
134158
childClasses: cls.childClassIdentifiers.map((identifier) => ({
135159
namespace: identifier.namespace,
136160
className: identifier.className,
137161
abstractClassName: `Abstract${identifier.className}`,
162+
asyncClassName: `Async${identifier.className}`,
163+
asyncAbstractClassName: `AbstractAsync${identifier.className}`,
138164
module: `${cls.namespace}_${identifier.namespace}`,
139165
})),
140166
importResolveActionAttempt,

codegen/lib/layouts/routes-index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@
55

66
import { pascalCase } from 'change-case'
77

8+
interface AbstractRoutesLayoutContext {
9+
className: string
10+
namespaces: Array<{ namespace: string; abstractClassName: string }>
11+
}
12+
813
export interface RoutesIndexLayoutContext {
914
namespaces: Array<{
1015
namespace: string
1116
className: string
1217
abstractClassName: string
18+
asyncClassName: string
19+
asyncAbstractClassName: string
1320
}>
14-
routesNamespaces: Array<{ namespace: string; abstractClassName: string }>
21+
abstractRoutes: AbstractRoutesLayoutContext
22+
asyncAbstractRoutes: AbstractRoutesLayoutContext
1523
}
1624

1725
export const setRoutesIndexLayoutContext = (
@@ -21,9 +29,21 @@ export const setRoutesIndexLayoutContext = (
2129
namespace: ns,
2230
className: pascalCase(ns),
2331
abstractClassName: `Abstract${pascalCase(ns)}`,
32+
asyncClassName: `Async${pascalCase(ns)}`,
33+
asyncAbstractClassName: `AbstractAsync${pascalCase(ns)}`,
2434
})),
25-
routesNamespaces: topLevelNamespaces.map((ns) => ({
26-
namespace: ns,
27-
abstractClassName: `Abstract${pascalCase(ns)}`,
28-
})),
35+
abstractRoutes: {
36+
className: 'AbstractRoutes',
37+
namespaces: topLevelNamespaces.map((ns) => ({
38+
namespace: ns,
39+
abstractClassName: `Abstract${pascalCase(ns)}`,
40+
})),
41+
},
42+
asyncAbstractRoutes: {
43+
className: 'AbstractAsyncRoutes',
44+
namespaces: topLevelNamespaces.map((ns) => ({
45+
namespace: ns,
46+
abstractClassName: `AbstractAsync${pascalCase(ns)}`,
47+
})),
48+
},
2949
})

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dev = [
2727
"pytest-watch>=4.2.0,<5",
2828
"rstcheck>=6.3.0,<7",
2929
"mypy>=2.3.0,<3",
30+
"pytest-asyncio>=1.0.0,<2",
3031
]
3132

3233
[build-system]
@@ -48,3 +49,5 @@ target-version = ["py311"]
4849
norecursedirs = [
4950
"node_modules"
5051
]
52+
asyncio_mode = "auto"
53+
asyncio_default_fixture_loop_scope = "function"

seam/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# flake8: noqa
22

3-
from .seam import Seam
4-
from .seam_without_workspace import SeamWithoutWorkspace
3+
from .seam import AsyncSeam, Seam
4+
from .seam_without_workspace import AsyncSeamWithoutWorkspace, SeamWithoutWorkspace
55
from httpx_retries import Retry
66
from .options import SeamInvalidOptionsError
77
from .auth import SeamInvalidTokenError

0 commit comments

Comments
 (0)