From 0e943fc873fe4299d093a39e9e0c36465a449286 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:31:45 +0000 Subject: [PATCH 1/2] fix: Read pagination from the response instead of a client hook Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY --- codegen/layouts/partials/route-method.hbs | 5 +- codegen/layouts/route.hbs | 3 + codegen/lib/layouts/route.ts | 4 + seam/pagination.py | 16 ++++ seam/paginator.py | 84 +++++--------------- seam/routes/access_codes.py | 23 ++++-- seam/routes/access_codes_unmanaged.py | 27 +++++-- seam/routes/access_grants.py | 23 ++++-- seam/routes/access_grants_unmanaged.py | 31 +++++--- seam/routes/access_methods.py | 23 ++++-- seam/routes/acs_credentials.py | 23 ++++-- seam/routes/acs_encoders.py | 23 ++++-- seam/routes/acs_entrances.py | 23 ++++-- seam/routes/acs_users.py | 23 ++++-- seam/routes/action_attempts.py | 23 ++++-- seam/routes/connect_webviews.py | 27 +++++-- seam/routes/connected_accounts.py | 31 +++++--- seam/routes/devices.py | 23 ++++-- seam/routes/devices_unmanaged.py | 23 ++++-- seam/routes/spaces.py | 21 +++-- seam/routes/user_identities.py | 23 ++++-- seam/routes/user_identities_unmanaged.py | 31 +++++--- test/paginator_isolation_test.py | 94 +++++++++++++++++++++++ 23 files changed, 417 insertions(+), 210 deletions(-) create mode 100644 test/paginator_isolation_test.py diff --git a/codegen/layouts/partials/route-method.hbs b/codegen/layouts/partials/route-method.hbs index 2ce36aa9..6867722e 100644 --- a/codegen/layouts/partials/route-method.hbs +++ b/codegen/layouts/partials/route-method.hbs @@ -35,7 +35,10 @@ return None {{else if (isListType returnType)}} - return [{{fromDict (listItemType returnType)}}(item) for item in unwrap_list(res, "{{returnPath.[0]}}", "{{path}}")] + return {{#if hasPagination}}PaginatedList( + [{{fromDict (listItemType returnType)}}(item) for item in unwrap_list(res, "{{returnPath.[0]}}", "{{path}}")], + pagination=res.get("pagination"), + ){{else}}[{{fromDict (listItemType returnType)}}(item) for item in unwrap_list(res, "{{returnPath.[0]}}", "{{path}}")]{{/if}} {{else}} return {{fromDict returnType}}(unwrap(res, "{{returnPath.[0]}}", "{{path}}")) diff --git a/codegen/layouts/route.hbs b/codegen/layouts/route.hbs index f5bdbbe0..bdac19a1 100644 --- a/codegen/layouts/route.hbs +++ b/codegen/layouts/route.hbs @@ -20,6 +20,9 @@ from ..response import unwrap {{#if importUnwrapList}} from ..response import unwrap_list {{/if}} +{{#if importPaginatedList}} +from ..pagination import PaginatedList +{{/if}} {{> abstract-route-class abstractClass}} diff --git a/codegen/lib/layouts/route.ts b/codegen/lib/layouts/route.ts index af08fec6..04550504 100644 --- a/codegen/lib/layouts/route.ts +++ b/codegen/lib/layouts/route.ts @@ -64,6 +64,7 @@ export interface RouteLayoutContext { importNull: boolean importUnwrap: boolean importUnwrapList: boolean + importPaginatedList: boolean methods: MethodLayoutContext[] } @@ -154,6 +155,8 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => { returnPath.length > 0 && returnType.startsWith('List['), ) + const importPaginatedList = methods.some(({ hasPagination }) => hasPagination) + const showPass = cls.methods.length === 0 && cls.childClassIdentifiers.length === 0 @@ -198,6 +201,7 @@ export const setRouteLayoutContext = (cls: ClassModel): RouteLayoutContext => { importNull, importUnwrap, importUnwrapList, + importPaginatedList, methods, } } diff --git a/seam/pagination.py b/seam/pagination.py index 52df67f1..a6cd346c 100644 --- a/seam/pagination.py +++ b/seam/pagination.py @@ -1,3 +1,6 @@ +from typing import Any, Dict, List, Optional + + class Pagination: def __init__( self, @@ -8,3 +11,16 @@ def __init__( self.has_next_page = has_next_page self.next_page_cursor = next_page_cursor self.next_page_url = next_page_url + + +class PaginatedList(List[Any]): + """A list of results that carries the response's pagination envelope. + + Behaves exactly like the plain list it replaces; the paginator reads + the ``pagination`` attribute instead of intercepting the response + through a client-wide event hook. + """ + + def __init__(self, items: List[Any], pagination: Optional[Dict[str, Any]] = None): + super().__init__(items) + self.pagination = pagination diff --git a/seam/paginator.py b/seam/paginator.py index 3f0829ac..6f2ad9db 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -8,9 +8,8 @@ Optional, Tuple, ) -from json import JSONDecodeError -from httpx import Response from .client import AsyncSeamHttpClient, SeamHttpClient +from .exceptions import SeamHttpInvalidResponseError from .pagination import Pagination @@ -22,6 +21,22 @@ def parse_pagination(pagination: Dict[str, Any]) -> Pagination: ) +def read_pagination(data: Any, request: Callable) -> Pagination: + """Read the pagination envelope a paginated route attaches to its result.""" + + pagination = getattr(data, "pagination", None) + + if not isinstance(pagination, dict): + path = getattr(request, "__seam_path__", "this endpoint") + raise SeamHttpInvalidResponseError( + path, + "pagination", + f"got {type(pagination).__name__} instead of a pagination object", + ) + + return parse_pagination(pagination) + + class SeamPaginator: """ Handles pagination for API list endpoints. @@ -29,8 +44,6 @@ class SeamPaginator: Iterates through pages of results returned by a callable function. """ - _FIRST_PAGE = "FIRST_PAGE" - def __init__( self, client: SeamHttpClient, @@ -48,19 +61,12 @@ def __init__( self._request = request self.client = client self._params = params or {} - self._pagination_cache: Dict[str, Pagination] = {} def first_page(self) -> Tuple[List[Any], Pagination | None]: """Fetches the first page of results.""" - self.client.event_hooks["response"].append( - lambda response: self._cache_pagination(response, self._FIRST_PAGE) - ) data = self._request(**self._params) - self.client.event_hooks["response"].pop() - pagination = self._pagination_cache.get(self._FIRST_PAGE) - - return data, pagination + return data, read_pagination(data, self._request) def next_page( self, next_page_cursor: str, / @@ -74,15 +80,9 @@ def next_page( "page_cursor": next_page_cursor, } - self.client.event_hooks["response"].append( - lambda response: self._cache_pagination(response, next_page_cursor) - ) data = self._request(**params) - self.client.event_hooks["response"].pop() - pagination = self._pagination_cache.get(next_page_cursor) - - return data, pagination + return data, read_pagination(data, self._request) def flatten_to_list(self) -> List[Any]: """Fetches all pages and returns all items as a single list.""" @@ -110,18 +110,6 @@ def flatten(self) -> Generator[Any, None, None]: if current_items: yield from current_items - def _cache_pagination(self, response: Response, page_key: str) -> None: - """Extracts pagination dict from response, creates Pagination object, and caches it.""" - try: - # httpx response hooks fire before the response body is read. - response.read() - pagination = response.json().get("pagination", {}) - except JSONDecodeError: - pagination = {} - - if isinstance(pagination, dict): - self._pagination_cache[page_key] = parse_pagination(pagination) - class AsyncSeamPaginator: """ @@ -130,8 +118,6 @@ class AsyncSeamPaginator: Iterates through pages of results returned by an awaitable function. """ - _FIRST_PAGE = "FIRST_PAGE" - def __init__( self, client: AsyncSeamHttpClient, @@ -149,21 +135,12 @@ def __init__( self._request = request self.client = client self._params = params or {} - self._pagination_cache: Dict[str, Pagination] = {} async def first_page(self) -> Tuple[List[Any], Pagination | None]: """Fetches the first page of results.""" - - async def cache_pagination(response: Response) -> None: - await self._cache_pagination(response, self._FIRST_PAGE) - - self.client.event_hooks["response"].append(cache_pagination) data = await self._request(**self._params) - self.client.event_hooks["response"].pop() - - pagination = self._pagination_cache.get(self._FIRST_PAGE) - return data, pagination + return data, read_pagination(data, self._request) async def next_page( self, next_page_cursor: str, / @@ -177,16 +154,9 @@ async def next_page( "page_cursor": next_page_cursor, } - async def cache_pagination(response: Response) -> None: - await self._cache_pagination(response, next_page_cursor) - - self.client.event_hooks["response"].append(cache_pagination) data = await self._request(**params) - self.client.event_hooks["response"].pop() - pagination = self._pagination_cache.get(next_page_cursor) - - return data, pagination + return data, read_pagination(data, self._request) async def flatten_to_list(self) -> List[Any]: """Fetches all pages and returns all items as a single list.""" @@ -217,15 +187,3 @@ async def flatten(self) -> AsyncGenerator[Any, None]: ) for item in current_items or []: yield item - - async def _cache_pagination(self, response: Response, page_key: str) -> None: - """Extracts pagination dict from response, creates Pagination object, and caches it.""" - try: - # httpx response hooks fire before the response body is read. - await response.aread() - pagination = response.json().get("pagination", {}) - except JSONDecodeError: - pagination = {} - - if isinstance(pagination, dict): - self._pagination_cache[page_key] = parse_pagination(pagination) diff --git a/seam/routes/access_codes.py b/seam/routes/access_codes.py index d262637f..499db93f 100644 --- a/seam/routes/access_codes.py +++ b/seam/routes/access_codes.py @@ -18,6 +18,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAccessCodes(abc.ABC): @@ -1140,10 +1141,13 @@ def list( res = self.client.get("/access_codes/list", params=params) - return [ - AccessCode.from_dict(item) - for item in unwrap_list(res, "access_codes", "/access_codes/list") - ] + return PaginatedList( + [ + AccessCode.from_dict(item) + for item in unwrap_list(res, "access_codes", "/access_codes/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_codes/pull_backup_access_code", @@ -1773,10 +1777,13 @@ async def list( res = await self.client.get("/access_codes/list", params=params) - return [ - AccessCode.from_dict(item) - for item in unwrap_list(res, "access_codes", "/access_codes/list") - ] + return PaginatedList( + [ + AccessCode.from_dict(item) + for item in unwrap_list(res, "access_codes", "/access_codes/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_codes/pull_backup_access_code", diff --git a/seam/routes/access_codes_unmanaged.py b/seam/routes/access_codes_unmanaged.py index e9000591..e556383a 100644 --- a/seam/routes/access_codes_unmanaged.py +++ b/seam/routes/access_codes_unmanaged.py @@ -6,6 +6,7 @@ from ..resources import UnmanagedAccessCode from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAccessCodesUnmanaged(abc.ABC): @@ -392,10 +393,15 @@ def list( res = self.client.get("/access_codes/unmanaged/list", params=params) - return [ - UnmanagedAccessCode.from_dict(item) - for item in unwrap_list(res, "access_codes", "/access_codes/unmanaged/list") - ] + return PaginatedList( + [ + UnmanagedAccessCode.from_dict(item) + for item in unwrap_list( + res, "access_codes", "/access_codes/unmanaged/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_codes/unmanaged/update", @@ -611,10 +617,15 @@ async def list( res = await self.client.get("/access_codes/unmanaged/list", params=params) - return [ - UnmanagedAccessCode.from_dict(item) - for item in unwrap_list(res, "access_codes", "/access_codes/unmanaged/list") - ] + return PaginatedList( + [ + UnmanagedAccessCode.from_dict(item) + for item in unwrap_list( + res, "access_codes", "/access_codes/unmanaged/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_codes/unmanaged/update", diff --git a/seam/routes/access_grants.py b/seam/routes/access_grants.py index 7ba565e7..07216ff2 100644 --- a/seam/routes/access_grants.py +++ b/seam/routes/access_grants.py @@ -12,6 +12,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAccessGrants(abc.ABC): @@ -798,10 +799,13 @@ def list( res = self.client.get("/access_grants/list", params=params) - return [ - AccessGrant.from_dict(item) - for item in unwrap_list(res, "access_grants", "/access_grants/list") - ] + return PaginatedList( + [ + AccessGrant.from_dict(item) + for item in unwrap_list(res, "access_grants", "/access_grants/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_grants/request_access_methods", @@ -1232,10 +1236,13 @@ async def list( res = await self.client.get("/access_grants/list", params=params) - return [ - AccessGrant.from_dict(item) - for item in unwrap_list(res, "access_grants", "/access_grants/list") - ] + return PaginatedList( + [ + AccessGrant.from_dict(item) + for item in unwrap_list(res, "access_grants", "/access_grants/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_grants/request_access_methods", diff --git a/seam/routes/access_grants_unmanaged.py b/seam/routes/access_grants_unmanaged.py index 9621abe9..db0621c3 100644 --- a/seam/routes/access_grants_unmanaged.py +++ b/seam/routes/access_grants_unmanaged.py @@ -6,6 +6,7 @@ from ..resources import UnmanagedAccessGrant from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAccessGrantsUnmanaged(abc.ABC): @@ -206,12 +207,15 @@ def list( res = self.client.get("/access_grants/unmanaged/list", params=params) - return [ - UnmanagedAccessGrant.from_dict(item) - for item in unwrap_list( - res, "access_grants", "/access_grants/unmanaged/list" - ) - ] + return PaginatedList( + [ + UnmanagedAccessGrant.from_dict(item) + for item in unwrap_list( + res, "access_grants", "/access_grants/unmanaged/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_grants/unmanaged/update", @@ -325,12 +329,15 @@ async def list( res = await self.client.get("/access_grants/unmanaged/list", params=params) - return [ - UnmanagedAccessGrant.from_dict(item) - for item in unwrap_list( - res, "access_grants", "/access_grants/unmanaged/list" - ) - ] + return PaginatedList( + [ + UnmanagedAccessGrant.from_dict(item) + for item in unwrap_list( + res, "access_grants", "/access_grants/unmanaged/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_grants/unmanaged/update", diff --git a/seam/routes/access_methods.py b/seam/routes/access_methods.py index 78b5d2f1..8e7576ae 100644 --- a/seam/routes/access_methods.py +++ b/seam/routes/access_methods.py @@ -16,6 +16,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAccessMethods(abc.ABC): @@ -677,10 +678,13 @@ def list( res = self.client.get("/access_methods/list", params=params) - return [ - AccessMethod.from_dict(item) - for item in unwrap_list(res, "access_methods", "/access_methods/list") - ] + return PaginatedList( + [ + AccessMethod.from_dict(item) + for item in unwrap_list(res, "access_methods", "/access_methods/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_methods/unlock_door", @@ -1038,10 +1042,13 @@ async def list( res = await self.client.get("/access_methods/list", params=params) - return [ - AccessMethod.from_dict(item) - for item in unwrap_list(res, "access_methods", "/access_methods/list") - ] + return PaginatedList( + [ + AccessMethod.from_dict(item) + for item in unwrap_list(res, "access_methods", "/access_methods/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/access_methods/unlock_door", diff --git a/seam/routes/acs_credentials.py b/seam/routes/acs_credentials.py index 224bee62..741d48b9 100644 --- a/seam/routes/acs_credentials.py +++ b/seam/routes/acs_credentials.py @@ -6,6 +6,7 @@ from ..resources import AcsCredential, AcsEntrance from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAcsCredentials(abc.ABC): @@ -565,10 +566,13 @@ def list( res = self.client.get("/acs/credentials/list", params=params) - return [ - AcsCredential.from_dict(item) - for item in unwrap_list(res, "acs_credentials", "/acs/credentials/list") - ] + return PaginatedList( + [ + AcsCredential.from_dict(item) + for item in unwrap_list(res, "acs_credentials", "/acs/credentials/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/credentials/list_accessible_entrances", @@ -889,10 +893,13 @@ async def list( res = await self.client.get("/acs/credentials/list", params=params) - return [ - AcsCredential.from_dict(item) - for item in unwrap_list(res, "acs_credentials", "/acs/credentials/list") - ] + return PaginatedList( + [ + AcsCredential.from_dict(item) + for item in unwrap_list(res, "acs_credentials", "/acs/credentials/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/credentials/list_accessible_entrances", diff --git a/seam/routes/acs_encoders.py b/seam/routes/acs_encoders.py index d1227a77..bb61675b 100644 --- a/seam/routes/acs_encoders.py +++ b/seam/routes/acs_encoders.py @@ -16,6 +16,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAcsEncoders(abc.ABC): @@ -351,10 +352,13 @@ def list( res = self.client.get("/acs/encoders/list", params=params) - return [ - AcsEncoder.from_dict(item) - for item in unwrap_list(res, "acs_encoders", "/acs/encoders/list") - ] + return PaginatedList( + [ + AcsEncoder.from_dict(item) + for item in unwrap_list(res, "acs_encoders", "/acs/encoders/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/encoders/scan_credential", @@ -576,10 +580,13 @@ async def list( res = await self.client.get("/acs/encoders/list", params=params) - return [ - AcsEncoder.from_dict(item) - for item in unwrap_list(res, "acs_encoders", "/acs/encoders/list") - ] + return PaginatedList( + [ + AcsEncoder.from_dict(item) + for item in unwrap_list(res, "acs_encoders", "/acs/encoders/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/encoders/scan_credential", diff --git a/seam/routes/acs_entrances.py b/seam/routes/acs_entrances.py index 6cf862e1..e462504f 100644 --- a/seam/routes/acs_entrances.py +++ b/seam/routes/acs_entrances.py @@ -15,6 +15,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAcsEntrances(abc.ABC): @@ -359,10 +360,13 @@ def list( res = self.client.get("/acs/entrances/list", params=params) - return [ - AcsEntrance.from_dict(item) - for item in unwrap_list(res, "acs_entrances", "/acs/entrances/list") - ] + return PaginatedList( + [ + AcsEntrance.from_dict(item) + for item in unwrap_list(res, "acs_entrances", "/acs/entrances/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/entrances/list_credentials_with_access", @@ -571,10 +575,13 @@ async def list( res = await self.client.get("/acs/entrances/list", params=params) - return [ - AcsEntrance.from_dict(item) - for item in unwrap_list(res, "acs_entrances", "/acs/entrances/list") - ] + return PaginatedList( + [ + AcsEntrance.from_dict(item) + for item in unwrap_list(res, "acs_entrances", "/acs/entrances/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/entrances/list_credentials_with_access", diff --git a/seam/routes/acs_users.py b/seam/routes/acs_users.py index 3cb1ced0..ca7535aa 100644 --- a/seam/routes/acs_users.py +++ b/seam/routes/acs_users.py @@ -6,6 +6,7 @@ from ..resources import AcsUser, AcsEntrance from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractAcsUsers(abc.ABC): @@ -760,10 +761,13 @@ def list( res = self.client.get("/acs/users/list", params=params) - return [ - AcsUser.from_dict(item) - for item in unwrap_list(res, "acs_users", "/acs/users/list") - ] + return PaginatedList( + [ + AcsUser.from_dict(item) + for item in unwrap_list(res, "acs_users", "/acs/users/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/users/list_accessible_entrances", @@ -1336,10 +1340,13 @@ async def list( res = await self.client.get("/acs/users/list", params=params) - return [ - AcsUser.from_dict(item) - for item in unwrap_list(res, "acs_users", "/acs/users/list") - ] + return PaginatedList( + [ + AcsUser.from_dict(item) + for item in unwrap_list(res, "acs_users", "/acs/users/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/acs/users/list_accessible_entrances", diff --git a/seam/routes/action_attempts.py b/seam/routes/action_attempts.py index 4e909852..db3a48da 100644 --- a/seam/routes/action_attempts.py +++ b/seam/routes/action_attempts.py @@ -10,6 +10,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractActionAttempts(abc.ABC): @@ -175,10 +176,13 @@ def list( res = self.client.get("/action_attempts/list", params=params) - return [ - action_attempt_from_dict(item) - for item in unwrap_list(res, "action_attempts", "/action_attempts/list") - ] + return PaginatedList( + [ + action_attempt_from_dict(item) + for item in unwrap_list(res, "action_attempts", "/action_attempts/list") + ], + pagination=res.get("pagination"), + ) class AsyncActionAttempts(AbstractAsyncActionAttempts): @@ -262,7 +266,10 @@ async def list( res = await self.client.get("/action_attempts/list", params=params) - return [ - action_attempt_from_dict(item) - for item in unwrap_list(res, "action_attempts", "/action_attempts/list") - ] + return PaginatedList( + [ + action_attempt_from_dict(item) + for item in unwrap_list(res, "action_attempts", "/action_attempts/list") + ], + pagination=res.get("pagination"), + ) diff --git a/seam/routes/connect_webviews.py b/seam/routes/connect_webviews.py index b7705df1..0fa2e0f8 100644 --- a/seam/routes/connect_webviews.py +++ b/seam/routes/connect_webviews.py @@ -6,6 +6,7 @@ from ..resources import ConnectWebview from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractConnectWebviews(abc.ABC): @@ -650,10 +651,15 @@ def list( res = self.client.get("/connect_webviews/list", params=params) - return [ - ConnectWebview.from_dict(item) - for item in unwrap_list(res, "connect_webviews", "/connect_webviews/list") - ] + return PaginatedList( + [ + ConnectWebview.from_dict(item) + for item in unwrap_list( + res, "connect_webviews", "/connect_webviews/list" + ) + ], + pagination=res.get("pagination"), + ) class AsyncConnectWebviews(AbstractAsyncConnectWebviews): @@ -922,7 +928,12 @@ async def list( res = await self.client.get("/connect_webviews/list", params=params) - return [ - ConnectWebview.from_dict(item) - for item in unwrap_list(res, "connect_webviews", "/connect_webviews/list") - ] + return PaginatedList( + [ + ConnectWebview.from_dict(item) + for item in unwrap_list( + res, "connect_webviews", "/connect_webviews/list" + ) + ], + pagination=res.get("pagination"), + ) diff --git a/seam/routes/connected_accounts.py b/seam/routes/connected_accounts.py index ce299918..eaf5d2a8 100644 --- a/seam/routes/connected_accounts.py +++ b/seam/routes/connected_accounts.py @@ -12,6 +12,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractConnectedAccounts(abc.ABC): @@ -357,12 +358,15 @@ def list( res = self.client.get("/connected_accounts/list", params=params) - return [ - ConnectedAccount.from_dict(item) - for item in unwrap_list( - res, "connected_accounts", "/connected_accounts/list" - ) - ] + return PaginatedList( + [ + ConnectedAccount.from_dict(item) + for item in unwrap_list( + res, "connected_accounts", "/connected_accounts/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/connected_accounts/sync", @@ -571,12 +575,15 @@ async def list( res = await self.client.get("/connected_accounts/list", params=params) - return [ - ConnectedAccount.from_dict(item) - for item in unwrap_list( - res, "connected_accounts", "/connected_accounts/list" - ) - ] + return PaginatedList( + [ + ConnectedAccount.from_dict(item) + for item in unwrap_list( + res, "connected_accounts", "/connected_accounts/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/connected_accounts/sync", diff --git a/seam/routes/devices.py b/seam/routes/devices.py index bef5beaa..08fb05ed 100644 --- a/seam/routes/devices.py +++ b/seam/routes/devices.py @@ -18,6 +18,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractDevices(abc.ABC): @@ -921,10 +922,13 @@ def list( res = self.client.get("/devices/list", params=params) - return [ - Device.from_dict(item) - for item in unwrap_list(res, "devices", "/devices/list") - ] + return PaginatedList( + [ + Device.from_dict(item) + for item in unwrap_list(res, "devices", "/devices/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/devices/list_device_providers", @@ -1339,10 +1343,13 @@ async def list( res = await self.client.get("/devices/list", params=params) - return [ - Device.from_dict(item) - for item in unwrap_list(res, "devices", "/devices/list") - ] + return PaginatedList( + [ + Device.from_dict(item) + for item in unwrap_list(res, "devices", "/devices/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/devices/list_device_providers", diff --git a/seam/routes/devices_unmanaged.py b/seam/routes/devices_unmanaged.py index 4e02ff54..7112889d 100644 --- a/seam/routes/devices_unmanaged.py +++ b/seam/routes/devices_unmanaged.py @@ -6,6 +6,7 @@ from ..resources import UnmanagedDevice from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractDevicesUnmanaged(abc.ABC): @@ -767,10 +768,13 @@ def list( res = self.client.get("/devices/unmanaged/list", params=params) - return [ - UnmanagedDevice.from_dict(item) - for item in unwrap_list(res, "devices", "/devices/unmanaged/list") - ] + return PaginatedList( + [ + UnmanagedDevice.from_dict(item) + for item in unwrap_list(res, "devices", "/devices/unmanaged/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/devices/unmanaged/update", @@ -1089,10 +1093,13 @@ async def list( res = await self.client.get("/devices/unmanaged/list", params=params) - return [ - UnmanagedDevice.from_dict(item) - for item in unwrap_list(res, "devices", "/devices/unmanaged/list") - ] + return PaginatedList( + [ + UnmanagedDevice.from_dict(item) + for item in unwrap_list(res, "devices", "/devices/unmanaged/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/devices/unmanaged/update", diff --git a/seam/routes/spaces.py b/seam/routes/spaces.py index 17da7bca..ff93a6b3 100644 --- a/seam/routes/spaces.py +++ b/seam/routes/spaces.py @@ -6,6 +6,7 @@ from ..resources import Space, Batch from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractSpaces(abc.ABC): @@ -747,9 +748,13 @@ def list( res = self.client.get("/spaces/list", params=params) - return [ - Space.from_dict(item) for item in unwrap_list(res, "spaces", "/spaces/list") - ] + return PaginatedList( + [ + Space.from_dict(item) + for item in unwrap_list(res, "spaces", "/spaces/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/spaces/remove_acs_entrances", @@ -1175,9 +1180,13 @@ async def list( res = await self.client.get("/spaces/list", params=params) - return [ - Space.from_dict(item) for item in unwrap_list(res, "spaces", "/spaces/list") - ] + return PaginatedList( + [ + Space.from_dict(item) + for item in unwrap_list(res, "spaces", "/spaces/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/spaces/remove_acs_entrances", diff --git a/seam/routes/user_identities.py b/seam/routes/user_identities.py index 272f2d50..223ee64e 100644 --- a/seam/routes/user_identities.py +++ b/seam/routes/user_identities.py @@ -19,6 +19,7 @@ ) from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractUserIdentities(abc.ABC): @@ -783,10 +784,13 @@ def list( res = self.client.get("/user_identities/list", params=params) - return [ - UserIdentity.from_dict(item) - for item in unwrap_list(res, "user_identities", "/user_identities/list") - ] + return PaginatedList( + [ + UserIdentity.from_dict(item) + for item in unwrap_list(res, "user_identities", "/user_identities/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/user_identities/list_accessible_devices", @@ -1312,10 +1316,13 @@ async def list( res = await self.client.get("/user_identities/list", params=params) - return [ - UserIdentity.from_dict(item) - for item in unwrap_list(res, "user_identities", "/user_identities/list") - ] + return PaginatedList( + [ + UserIdentity.from_dict(item) + for item in unwrap_list(res, "user_identities", "/user_identities/list") + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/user_identities/list_accessible_devices", diff --git a/seam/routes/user_identities_unmanaged.py b/seam/routes/user_identities_unmanaged.py index 3bd414c3..0d6b03a7 100644 --- a/seam/routes/user_identities_unmanaged.py +++ b/seam/routes/user_identities_unmanaged.py @@ -6,6 +6,7 @@ from ..resources import UnmanagedUserIdentity from ..response import unwrap from ..response import unwrap_list +from ..pagination import PaginatedList class AbstractUserIdentitiesUnmanaged(abc.ABC): @@ -180,12 +181,15 @@ def list( res = self.client.get("/user_identities/unmanaged/list", params=params) - return [ - UnmanagedUserIdentity.from_dict(item) - for item in unwrap_list( - res, "user_identities", "/user_identities/unmanaged/list" - ) - ] + return PaginatedList( + [ + UnmanagedUserIdentity.from_dict(item) + for item in unwrap_list( + res, "user_identities", "/user_identities/unmanaged/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/user_identities/unmanaged/update", @@ -287,12 +291,15 @@ async def list( res = await self.client.get("/user_identities/unmanaged/list", params=params) - return [ - UnmanagedUserIdentity.from_dict(item) - for item in unwrap_list( - res, "user_identities", "/user_identities/unmanaged/list" - ) - ] + return PaginatedList( + [ + UnmanagedUserIdentity.from_dict(item) + for item in unwrap_list( + res, "user_identities", "/user_identities/unmanaged/list" + ) + ], + pagination=res.get("pagination"), + ) @route_metadata( path="/user_identities/unmanaged/update", diff --git a/test/paginator_isolation_test.py b/test/paginator_isolation_test.py new file mode 100644 index 00000000..fa6d8064 --- /dev/null +++ b/test/paginator_isolation_test.py @@ -0,0 +1,94 @@ +import asyncio + +import pytest + +from seam import AsyncSeam, Seam, SeamHttpApiError, SeamHttpInvalidResponseError +from seam.pagination import PaginatedList + + +def test_paginated_routes_return_a_plain_list_with_the_envelope(seam: Seam): + connected_accounts = seam.connected_accounts.list() + + assert isinstance(connected_accounts, list) + assert isinstance(connected_accounts, PaginatedList) + assert isinstance(connected_accounts.pagination, dict) + + +def test_paginator_leaves_no_hooks_on_the_client(seam: Seam): + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 1}) + paginator.flatten_to_list() + + assert len(seam.client.event_hooks["response"]) == 0 + + +def test_a_failed_page_request_leaves_no_hooks_on_the_client(recording_server): + with recording_server( + [(500, {"error": {"type": "internal_error", "message": "Down"}})] + ) as (endpoint, _): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + paginator = seam.create_paginator(seam.devices.list) + + with pytest.raises(SeamHttpApiError): + paginator.first_page() + + assert len(seam.client.event_hooks["response"]) == 0 + + +def test_a_missing_pagination_envelope_raises(recording_server): + with recording_server([(200, {"devices": []})]) as (endpoint, _): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + paginator = seam.create_paginator(seam.devices.list) + + with pytest.raises( + SeamHttpInvalidResponseError, + match="Seam returned an invalid response for /devices/list: " + 'expected "pagination", got NoneType instead of a pagination object', + ): + paginator.first_page() + + +def test_a_non_object_pagination_envelope_raises(recording_server): + with recording_server([(200, {"devices": [], "pagination": "bogus"})]) as ( + endpoint, + _, + ): + seam = Seam.from_api_key("seam_apikey_token", endpoint=endpoint) + paginator = seam.create_paginator(seam.devices.list) + + with pytest.raises( + SeamHttpInvalidResponseError, + match='expected "pagination", got str instead of a pagination object', + ): + paginator.first_page() + + +async def test_concurrent_paginators_do_not_interfere(async_seam: AsyncSeam): + all_connected_accounts = await async_seam.connected_accounts.list() + + first = async_seam.create_paginator( + async_seam.connected_accounts.list, {"limit": 1} + ) + second = async_seam.create_paginator( + async_seam.connected_accounts.list, {"limit": 2} + ) + + first_items, second_items = await asyncio.gather( + first.flatten_to_list(), second.flatten_to_list() + ) + + assert len(first_items) == len(all_connected_accounts) + assert len(second_items) == len(all_connected_accounts) + assert len(async_seam.client.event_hooks["response"]) == 0 + + +async def test_a_missing_pagination_envelope_raises_async(recording_server): + with recording_server([(200, {"devices": []})]) as (endpoint, _): + async with AsyncSeam(api_key="seam_apikey_token", endpoint=endpoint) as seam: + paginator = seam.create_paginator(seam.devices.list) + + with pytest.raises( + SeamHttpInvalidResponseError, + match='expected "pagination", got NoneType instead of a ' + "pagination object", + ): + await paginator.first_page() From cf4d848c1614a6b46f08584ba3ab0b16a58376e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 21:36:30 +0000 Subject: [PATCH 2/2] test: Bump fake-seam-connect for access code list pagination Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa376186..1e775908 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "@seamapi/python", "devDependencies": { "@seamapi/blueprint": "^1.10.0", - "@seamapi/fake-seam-connect": "2.0.5", + "@seamapi/fake-seam-connect": "2.0.6", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1047.0", "change-case": "^5.4.4", @@ -802,9 +802,9 @@ } }, "node_modules/@seamapi/fake-seam-connect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-2.0.5.tgz", - "integrity": "sha512-dnHoZtHUHyQP9yqduEqE8dfmOJGMMt0MuqviwgOZo8ElAaPoZk/G+QM5MDg2LJ6q0t54kARYjSngp+IfTme6TQ==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@seamapi/fake-seam-connect/-/fake-seam-connect-2.0.6.tgz", + "integrity": "sha512-lwesSVm0AhQ57QuV52iGTI2qkK++phcY0K/xkalsUWdbwyPK4cj2VfJuW3yes32HuTaXkwPWlT9lrIhLJzZOnQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index ec8a307c..85b6576d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "packageManager": "npm@11.19.0", "devDependencies": { "@seamapi/blueprint": "^1.10.0", - "@seamapi/fake-seam-connect": "2.0.5", + "@seamapi/fake-seam-connect": "2.0.6", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1047.0", "change-case": "^5.4.4",