From 8c1cd820331c16626bc25e5f990f23d625faddb0 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Tue, 15 Apr 2025 16:31:17 +0200 Subject: [PATCH 01/25] Add paginator class --- seam/paginator.py | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 seam/paginator.py diff --git a/seam/paginator.py b/seam/paginator.py new file mode 100644 index 0000000..ff1c6c3 --- /dev/null +++ b/seam/paginator.py @@ -0,0 +1,102 @@ +from typing import Callable, Dict, Any, Tuple, Generator, List + + +class Pagination: + def __init__( + self, + has_next_page: bool, + next_page_cursor: str | None, + next_page_url: str | None, + ): + self.has_next_page = has_next_page + self.next_page_cursor = next_page_cursor + self.next_page_url = next_page_url + + +class Paginator: + """ + Handles pagination for API list endpoints. + + Iterates through pages of results returned by a callable function. + """ + + _FIRST_PAGE = "FIRST_PAGE" + + def __init__(self, callable_func: Callable, params: Dict[str, Any] = None): + """ + Initializes the Paginator. + + Args: + callable_func: The function to call to fetch a page of data. + params: Initial parameters to pass to the callable function. + """ + self._callable_func = callable_func + self._params = params or {} + self._pagination_cache: Dict[str, Pagination] = {} + + def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: + """Extracts pagination dict from response, creates Pagination object, and caches it.""" + pagination_dict = response.get("pagination") + + if isinstance(pagination_dict, dict): + pagination_obj = Pagination( + has_next_page=pagination_dict.get("has_next_page", False), + next_page_cursor=pagination_dict.get("next_page_cursor"), + next_page_url=pagination_dict.get("next_page_url"), + ) + self._pagination_cache[page_key] = pagination_obj + + def first_page(self) -> Tuple[List[Any], Pagination | None]: + """Fetches the first page of results.""" + params = self._params.copy() + + params["on_response"] = lambda response: self._cache_pagination( + response, self._FIRST_PAGE + ) + + data = self._callable_func(**params) + pagination = self._pagination_cache.get(self._FIRST_PAGE) + + return data, pagination + + def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None]: + """Fetches the next page of results using a cursor.""" + if not next_page_cursor: + return [], None + + params = self._params.copy() + params["page_cursor"] = next_page_cursor + params["on_response"] = lambda response: self._cache_pagination( + response, next_page_cursor + ) + + data = self._callable_func(**params) + pagination = self._pagination_cache.get(next_page_cursor) + + return data, pagination + + def flatten_to_list(self) -> List[Any]: + """Fetches all pages and returns all items as a single list.""" + all_items = [] + current_items, pagination = self.first_page() + + if current_items: + all_items.extend(current_items) + + while pagination and pagination.has_next_page and pagination.next_page_cursor: + current_items, pagination = self.next_page(pagination.next_page_cursor) + if current_items: + all_items.extend(current_items) + + return all_items + + def flatten(self) -> Generator[Any, None, None]: + """Fetches all pages and yields items one by one using a generator.""" + current_items, pagination = self.first_page() + if current_items: + yield from current_items + + while pagination and pagination.has_next_page and pagination.next_page_cursor: + current_items, pagination = self.next_page(pagination.next_page_cursor) + if current_items: + yield from current_items From 17c7581f46ac4500e95172c7698ada8c06afb00e Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Tue, 15 Apr 2025 16:31:55 +0200 Subject: [PATCH 02/25] Disable generation, generate manually --- .github/workflows/generate.yml | 5 ++--- seam/routes/acs_users.py | 8 ++++++-- seam/routes/connected_accounts.py | 8 ++++++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 9fc5654..703b21a 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -3,9 +3,8 @@ name: Generate on: push: - branches-ignore: - - main - workflow_dispatch: {} + branches: + - nonexistent-branch jobs: commit: diff --git a/seam/routes/acs_users.py b/seam/routes/acs_users.py index c1a04b6..4931095 100644 --- a/seam/routes/acs_users.py +++ b/seam/routes/acs_users.py @@ -1,4 +1,4 @@ -from typing import Optional, Any, List, Dict, Union +from typing import Optional, Any, List, Dict, Union, Callable from ..client import SeamHttpClient from .models import AbstractAcsUsers, AcsUser, AcsEntrance @@ -87,7 +87,8 @@ def list( search: Optional[str] = None, user_identity_email_address: Optional[str] = None, user_identity_id: Optional[str] = None, - user_identity_phone_number: Optional[str] = None + user_identity_phone_number: Optional[str] = None, + on_response: Optional[Callable] = None ) -> List[AcsUser]: json_payload = {} @@ -110,6 +111,9 @@ def list( res = self.client.post("/acs/users/list", json=json_payload) + if on_response is not None: + on_response(res) + return [AcsUser.from_dict(item) for item in res["acs_users"]] def list_accessible_entrances(self, *, acs_user_id: str) -> List[AcsEntrance]: diff --git a/seam/routes/connected_accounts.py b/seam/routes/connected_accounts.py index 589bd9f..c2164ba 100644 --- a/seam/routes/connected_accounts.py +++ b/seam/routes/connected_accounts.py @@ -1,4 +1,4 @@ -from typing import Optional, Any, List, Dict, Union +from typing import Optional, Any, List, Dict, Union, Callable from ..client import SeamHttpClient from .models import AbstractConnectedAccounts, ConnectedAccount @@ -40,7 +40,8 @@ def list( custom_metadata_has: Optional[Dict[str, Any]] = None, limit: Optional[int] = None, page_cursor: Optional[str] = None, - user_identifier_key: Optional[str] = None + user_identifier_key: Optional[str] = None, + on_response: Optional[Callable] = None ) -> List[ConnectedAccount]: json_payload = {} @@ -55,6 +56,9 @@ def list( res = self.client.post("/connected_accounts/list", json=json_payload) + if on_response is not None: + on_response(res) + return [ConnectedAccount.from_dict(item) for item in res["connected_accounts"]] def update( From cabf72b978b95cd5179596dfb869f2ffd95a0a0f Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Tue, 15 Apr 2025 16:34:57 +0200 Subject: [PATCH 03/25] Test paginator --- seam/seam.py | 28 +++++++++++++++++++- test/paginator_test.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 test/paginator_test.py diff --git a/seam/seam.py b/seam/seam.py index be1c7be..04e5533 100644 --- a/seam/seam.py +++ b/seam/seam.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, Union, Dict +from typing import Any, Optional, Union, Dict, Callable from typing_extensions import Self from urllib3.util.retry import Retry @@ -7,6 +7,7 @@ from .routes import Routes from .models import AbstractSeam from .client import SeamHttpClient +from .paginator import Paginator class Seam(AbstractSeam): @@ -89,6 +90,31 @@ def __init__( Routes.__init__(self, client=self.client, defaults=self.defaults) + def create_paginator( + self, request: Callable[..., Any], params: Optional[Dict[str, Any]] = None + ) -> Paginator: + """ + Creates a Paginator instance for iterating through list endpoints. + + This is a helper method to simplify the process of paginating through + API results. + + Args: + request: The API route method function to call for fetching pages + (e.g., connected_accounts.list). + params: Optional dictionary of initial parameters to pass to the request + function. + + Returns: + An initialized Paginator object ready to fetch pages. + + Example: + >>> connected_accounts_paginator = seam.create_paginator(seam.connected_accounts.list) + >>> for connected_account in connected_accounts_paginator.flatten(): + >>> print(connected_account.account_type_display_name) + """ + return Paginator(request, params) + @classmethod def from_api_key( cls, diff --git a/test/paginator_test.py b/test/paginator_test.py new file mode 100644 index 0000000..d662f9d --- /dev/null +++ b/test/paginator_test.py @@ -0,0 +1,60 @@ +from seam import Seam + + +def test_paginator_first_page(seam: Seam): + paginator = seam.create_paginator( + seam.connected_accounts.list, {"limit": 2} + ) + connected_accounts, pagination = paginator.first_page() + + assert isinstance(connected_accounts, list) + assert len(connected_accounts) == 2 + assert pagination is not None + assert pagination.has_next_page is True + assert pagination.next_page_cursor is not None + assert pagination.next_page_url is not None + + +def test_paginator_next_page(seam: Seam): + paginator = seam.create_paginator( + seam.connected_accounts.list, params={"limit": 2} + ) + first_page_accounts, first_pagination = paginator.first_page() + + assert len(first_page_accounts) == 2 + assert first_pagination.has_next_page is True + + next_page_accounts, _ = paginator.next_page( + first_pagination.next_page_cursor + ) + + assert isinstance(next_page_accounts, list) + assert len(next_page_accounts) == 1 + + +def test_paginator_flatten_to_list(seam: Seam): + all_connected_accounts = seam.connected_accounts.list() + + paginator = seam.create_paginator( + seam.connected_accounts.list, params={"limit": 1} + ) + paginated_accounts = paginator.flatten_to_list() + + assert len(paginated_accounts) > 1 + assert len(paginated_accounts) == len(all_connected_accounts) + + +def test_paginator_flatten(seam: Seam): + all_connected_accounts = seam.connected_accounts.list() + + paginator = seam.create_paginator( + seam.connected_accounts.list, params={"limit": 1} + ) + + collected_accounts = [] + for account in paginator.flatten(): + collected_accounts.append(account) + + + assert len(collected_accounts) > 1 + assert len(collected_accounts) == len(all_connected_accounts) From a7935dfb90623d95fcd61c45e0d39bd8ee280861 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Tue, 15 Apr 2025 14:35:25 +0000 Subject: [PATCH 04/25] ci: Format code --- test/paginator_test.py | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/test/paginator_test.py b/test/paginator_test.py index d662f9d..d453093 100644 --- a/test/paginator_test.py +++ b/test/paginator_test.py @@ -2,9 +2,7 @@ def test_paginator_first_page(seam: Seam): - paginator = seam.create_paginator( - seam.connected_accounts.list, {"limit": 2} - ) + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 2}) connected_accounts, pagination = paginator.first_page() assert isinstance(connected_accounts, list) @@ -16,17 +14,13 @@ def test_paginator_first_page(seam: Seam): def test_paginator_next_page(seam: Seam): - paginator = seam.create_paginator( - seam.connected_accounts.list, params={"limit": 2} - ) + paginator = seam.create_paginator(seam.connected_accounts.list, params={"limit": 2}) first_page_accounts, first_pagination = paginator.first_page() assert len(first_page_accounts) == 2 assert first_pagination.has_next_page is True - next_page_accounts, _ = paginator.next_page( - first_pagination.next_page_cursor - ) + next_page_accounts, _ = paginator.next_page(first_pagination.next_page_cursor) assert isinstance(next_page_accounts, list) assert len(next_page_accounts) == 1 @@ -35,9 +29,7 @@ def test_paginator_next_page(seam: Seam): def test_paginator_flatten_to_list(seam: Seam): all_connected_accounts = seam.connected_accounts.list() - paginator = seam.create_paginator( - seam.connected_accounts.list, params={"limit": 1} - ) + paginator = seam.create_paginator(seam.connected_accounts.list, params={"limit": 1}) paginated_accounts = paginator.flatten_to_list() assert len(paginated_accounts) > 1 @@ -47,14 +39,11 @@ def test_paginator_flatten_to_list(seam: Seam): def test_paginator_flatten(seam: Seam): all_connected_accounts = seam.connected_accounts.list() - paginator = seam.create_paginator( - seam.connected_accounts.list, params={"limit": 1} - ) + paginator = seam.create_paginator(seam.connected_accounts.list, params={"limit": 1}) collected_accounts = [] for account in paginator.flatten(): collected_accounts.append(account) - assert len(collected_accounts) > 1 assert len(collected_accounts) == len(all_connected_accounts) From ca6e425b0346f16c917bb1502b5a386a06795939 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Tue, 15 Apr 2025 16:36:53 +0200 Subject: [PATCH 05/25] Update type --- seam/seam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seam/seam.py b/seam/seam.py index 04e5533..37173fa 100644 --- a/seam/seam.py +++ b/seam/seam.py @@ -91,7 +91,7 @@ def __init__( Routes.__init__(self, client=self.client, defaults=self.defaults) def create_paginator( - self, request: Callable[..., Any], params: Optional[Dict[str, Any]] = None + self, request: Callable, params: Optional[Dict[str, Any]] = None ) -> Paginator: """ Creates a Paginator instance for iterating through list endpoints. From d844cf613323f09bfaa4a160b5a809ded26735a2 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 10:59:58 +0200 Subject: [PATCH 06/25] Rename callable_func to request --- seam/paginator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index ff1c6c3..90cdcab 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -22,15 +22,15 @@ class Paginator: _FIRST_PAGE = "FIRST_PAGE" - def __init__(self, callable_func: Callable, params: Dict[str, Any] = None): + def __init__(self, request: Callable, params: Dict[str, Any] = None): """ Initializes the Paginator. Args: - callable_func: The function to call to fetch a page of data. + request: The function to call to fetch a page of data. params: Initial parameters to pass to the callable function. """ - self._callable_func = callable_func + self._request = request self._params = params or {} self._pagination_cache: Dict[str, Pagination] = {} @@ -54,7 +54,7 @@ def first_page(self) -> Tuple[List[Any], Pagination | None]: response, self._FIRST_PAGE ) - data = self._callable_func(**params) + data = self._request(**params) pagination = self._pagination_cache.get(self._FIRST_PAGE) return data, pagination @@ -70,7 +70,7 @@ def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None response, next_page_cursor ) - data = self._callable_func(**params) + data = self._request(**params) pagination = self._pagination_cache.get(next_page_cursor) return data, pagination From 78e49d08045fe727f5a8bb1bf66a0363a5eef639 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 11:01:05 +0200 Subject: [PATCH 07/25] Update how params are passed to create_paginator in test --- test/paginator_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/paginator_test.py b/test/paginator_test.py index d453093..f8ba6fa 100644 --- a/test/paginator_test.py +++ b/test/paginator_test.py @@ -14,7 +14,7 @@ def test_paginator_first_page(seam: Seam): def test_paginator_next_page(seam: Seam): - paginator = seam.create_paginator(seam.connected_accounts.list, params={"limit": 2}) + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 2}) first_page_accounts, first_pagination = paginator.first_page() assert len(first_page_accounts) == 2 @@ -29,7 +29,7 @@ def test_paginator_next_page(seam: Seam): def test_paginator_flatten_to_list(seam: Seam): all_connected_accounts = seam.connected_accounts.list() - paginator = seam.create_paginator(seam.connected_accounts.list, params={"limit": 1}) + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 1}) paginated_accounts = paginator.flatten_to_list() assert len(paginated_accounts) > 1 @@ -39,7 +39,7 @@ def test_paginator_flatten_to_list(seam: Seam): def test_paginator_flatten(seam: Seam): all_connected_accounts = seam.connected_accounts.list() - paginator = seam.create_paginator(seam.connected_accounts.list, params={"limit": 1}) + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 1}) collected_accounts = [] for account in paginator.flatten(): From 284ce7ea100d43ede182802554a2cc4e7c37b784 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:15:05 +0200 Subject: [PATCH 08/25] Simplify flatten_to_list check, default response pagination to {} --- seam/paginator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index 90cdcab..41e6678 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -36,7 +36,7 @@ def __init__(self, request: Callable, params: Dict[str, Any] = None): def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: """Extracts pagination dict from response, creates Pagination object, and caches it.""" - pagination_dict = response.get("pagination") + pagination_dict = response.get("pagination", {}) if isinstance(pagination_dict, dict): pagination_obj = Pagination( @@ -83,7 +83,7 @@ def flatten_to_list(self) -> List[Any]: if current_items: all_items.extend(current_items) - while pagination and pagination.has_next_page and pagination.next_page_cursor: + while pagination.has_next_page: current_items, pagination = self.next_page(pagination.next_page_cursor) if current_items: all_items.extend(current_items) From d3662fe42272a030394d9d9ab24c022932ce4153 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:17:26 +0200 Subject: [PATCH 09/25] next_page should throw on null next_page_cursor --- seam/paginator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/seam/paginator.py b/seam/paginator.py index 41e6678..c8d3435 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -62,7 +62,9 @@ def first_page(self) -> Tuple[List[Any], Pagination | None]: def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None]: """Fetches the next page of results using a cursor.""" if not next_page_cursor: - return [], None + raise ValueError( + "Cannot get the next page with a null next_page_cursor." + ) params = self._params.copy() params["page_cursor"] = next_page_cursor From a0dba87d44d9c52f0ec6380f651961d75a6ea585 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:22:36 +0200 Subject: [PATCH 10/25] Use literal spread syntax --- seam/paginator.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index c8d3435..610e2d0 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -48,11 +48,12 @@ def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: def first_page(self) -> Tuple[List[Any], Pagination | None]: """Fetches the first page of results.""" - params = self._params.copy() - - params["on_response"] = lambda response: self._cache_pagination( - response, self._FIRST_PAGE - ) + params = { + **self._params, + "on_response": lambda response: self._cache_pagination( + response, self._FIRST_PAGE + ), + } data = self._request(**params) pagination = self._pagination_cache.get(self._FIRST_PAGE) @@ -62,15 +63,15 @@ def first_page(self) -> Tuple[List[Any], Pagination | None]: def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None]: """Fetches the next page of results using a cursor.""" if not next_page_cursor: - raise ValueError( - "Cannot get the next page with a null next_page_cursor." - ) - - params = self._params.copy() - params["page_cursor"] = next_page_cursor - params["on_response"] = lambda response: self._cache_pagination( - response, next_page_cursor - ) + raise ValueError("Cannot get the next page with a null next_page_cursor.") + + params = { + **self._params, + "page_cursor": next_page_cursor, + "on_response": lambda response: self._cache_pagination( + response, next_page_cursor + ), + } data = self._request(**params) pagination = self._pagination_cache.get(next_page_cursor) From 1e056b6c6abf6bac8b478209fb55f460c908633f Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <84702959+andrii-balitskyi@users.noreply.github.com> Date: Wed, 16 Apr 2025 12:23:10 +0200 Subject: [PATCH 11/25] Update seam/paginator.py Co-authored-by: Evan Sosenko --- seam/paginator.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index 610e2d0..876f893 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -39,12 +39,11 @@ def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: pagination_dict = response.get("pagination", {}) if isinstance(pagination_dict, dict): - pagination_obj = Pagination( - has_next_page=pagination_dict.get("has_next_page", False), - next_page_cursor=pagination_dict.get("next_page_cursor"), - next_page_url=pagination_dict.get("next_page_url"), + self._pagination_cache[page_key] = Pagination( + has_next_page=pagination.get("has_next_page", False), + next_page_cursor=pagination.get("next_page_cursor"), + next_page_url=pagination.get("next_page_url"), ) - self._pagination_cache[page_key] = pagination_obj def first_page(self) -> Tuple[List[Any], Pagination | None]: """Fetches the first page of results.""" From 6e55aaca843e67905da89bf3b3f8810042e1b1f2 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:24:20 +0200 Subject: [PATCH 12/25] Fix var name --- seam/paginator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index 876f893..ab8bee8 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -36,9 +36,9 @@ def __init__(self, request: Callable, params: Dict[str, Any] = None): def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: """Extracts pagination dict from response, creates Pagination object, and caches it.""" - pagination_dict = response.get("pagination", {}) + pagination = response.get("pagination", {}) - if isinstance(pagination_dict, dict): + if isinstance(pagination, dict): self._pagination_cache[page_key] = Pagination( has_next_page=pagination.get("has_next_page", False), next_page_cursor=pagination.get("next_page_cursor"), From b071eab20d2bcf652ff66e7fa730273efffa2cb5 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:25:12 +0200 Subject: [PATCH 13/25] Move private method to the end of class body --- seam/paginator.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index ab8bee8..e89e36c 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -34,17 +34,6 @@ def __init__(self, request: Callable, params: Dict[str, Any] = None): self._params = params or {} self._pagination_cache: Dict[str, Pagination] = {} - def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: - """Extracts pagination dict from response, creates Pagination object, and caches it.""" - pagination = response.get("pagination", {}) - - if isinstance(pagination, dict): - self._pagination_cache[page_key] = Pagination( - has_next_page=pagination.get("has_next_page", False), - next_page_cursor=pagination.get("next_page_cursor"), - next_page_url=pagination.get("next_page_url"), - ) - def first_page(self) -> Tuple[List[Any], Pagination | None]: """Fetches the first page of results.""" params = { @@ -102,3 +91,14 @@ def flatten(self) -> Generator[Any, None, None]: current_items, pagination = self.next_page(pagination.next_page_cursor) if current_items: yield from current_items + + def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: + """Extracts pagination dict from response, creates Pagination object, and caches it.""" + pagination = response.get("pagination", {}) + + if isinstance(pagination, dict): + self._pagination_cache[page_key] = Pagination( + has_next_page=pagination.get("has_next_page", False), + next_page_cursor=pagination.get("next_page_cursor"), + next_page_url=pagination.get("next_page_url"), + ) From 691d8ee823fc31ddbe08adb52f39aaed966266f4 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:55:34 +0200 Subject: [PATCH 14/25] Use niquests hooks instead of on_response callback --- seam/paginator.py | 39 ++++++++++++++++++++++++++------------- seam/seam.py | 2 +- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index e89e36c..7281746 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -1,4 +1,6 @@ from typing import Callable, Dict, Any, Tuple, Generator, List +from .client import SeamHttpClient +from niquests import Response, JSONDecodeError class Pagination: @@ -22,28 +24,33 @@ class Paginator: _FIRST_PAGE = "FIRST_PAGE" - def __init__(self, request: Callable, params: Dict[str, Any] = None): + def __init__( + self, + request: Callable, + http_client: SeamHttpClient, + params: Dict[str, Any] = None, + ): """ Initializes the Paginator. Args: request: The function to call to fetch a page of data. + http_client: The HTTP client used in the request. params: Initial parameters to pass to the callable function. """ self._request = request + self._http_client = http_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.""" - params = { - **self._params, - "on_response": lambda response: self._cache_pagination( - response, self._FIRST_PAGE - ), - } + self._http_client.hooks["response"].append( + lambda response: self._cache_pagination(response, self._FIRST_PAGE) + ) + data = self._request(**self._params) + self._http_client.hooks["response"].pop() - data = self._request(**params) pagination = self._pagination_cache.get(self._FIRST_PAGE) return data, pagination @@ -56,12 +63,14 @@ def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None params = { **self._params, "page_cursor": next_page_cursor, - "on_response": lambda response: self._cache_pagination( - response, next_page_cursor - ), } + self._http_client.hooks["response"].append( + lambda response: self._cache_pagination(response, next_page_cursor) + ) data = self._request(**params) + self._http_client.hooks["response"].pop() + pagination = self._pagination_cache.get(next_page_cursor) return data, pagination @@ -92,9 +101,13 @@ def flatten(self) -> Generator[Any, None, None]: if current_items: yield from current_items - def _cache_pagination(self, response: Dict[str, Any], page_key: str) -> None: + def _cache_pagination(self, response: Response, page_key: str) -> None: """Extracts pagination dict from response, creates Pagination object, and caches it.""" - pagination = response.get("pagination", {}) + try: + response_json = response.json() + pagination = response_json.get("pagination", {}) + except JSONDecodeError: + pagination = {} if isinstance(pagination, dict): self._pagination_cache[page_key] = Pagination( diff --git a/seam/seam.py b/seam/seam.py index 37173fa..c747800 100644 --- a/seam/seam.py +++ b/seam/seam.py @@ -113,7 +113,7 @@ def create_paginator( >>> for connected_account in connected_accounts_paginator.flatten(): >>> print(connected_account.account_type_display_name) """ - return Paginator(request, params) + return Paginator(request, self.client, params) @classmethod def from_api_key( From f3ac72b434a71966732067d571f39a67bd6b0d41 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:56:04 +0200 Subject: [PATCH 15/25] Reenable generation --- .github/workflows/generate.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 703b21a..9fc5654 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -3,8 +3,9 @@ name: Generate on: push: - branches: - - nonexistent-branch + branches-ignore: + - main + workflow_dispatch: {} jobs: commit: From bd859a6b821d0a4d728d43cd84c44010d48d70e4 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Wed, 16 Apr 2025 10:56:41 +0000 Subject: [PATCH 16/25] ci: Generate code --- seam/routes/acs_users.py | 8 ++------ seam/routes/connected_accounts.py | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/seam/routes/acs_users.py b/seam/routes/acs_users.py index 4931095..c1a04b6 100644 --- a/seam/routes/acs_users.py +++ b/seam/routes/acs_users.py @@ -1,4 +1,4 @@ -from typing import Optional, Any, List, Dict, Union, Callable +from typing import Optional, Any, List, Dict, Union from ..client import SeamHttpClient from .models import AbstractAcsUsers, AcsUser, AcsEntrance @@ -87,8 +87,7 @@ def list( search: Optional[str] = None, user_identity_email_address: Optional[str] = None, user_identity_id: Optional[str] = None, - user_identity_phone_number: Optional[str] = None, - on_response: Optional[Callable] = None + user_identity_phone_number: Optional[str] = None ) -> List[AcsUser]: json_payload = {} @@ -111,9 +110,6 @@ def list( res = self.client.post("/acs/users/list", json=json_payload) - if on_response is not None: - on_response(res) - return [AcsUser.from_dict(item) for item in res["acs_users"]] def list_accessible_entrances(self, *, acs_user_id: str) -> List[AcsEntrance]: diff --git a/seam/routes/connected_accounts.py b/seam/routes/connected_accounts.py index c2164ba..589bd9f 100644 --- a/seam/routes/connected_accounts.py +++ b/seam/routes/connected_accounts.py @@ -1,4 +1,4 @@ -from typing import Optional, Any, List, Dict, Union, Callable +from typing import Optional, Any, List, Dict, Union from ..client import SeamHttpClient from .models import AbstractConnectedAccounts, ConnectedAccount @@ -40,8 +40,7 @@ def list( custom_metadata_has: Optional[Dict[str, Any]] = None, limit: Optional[int] = None, page_cursor: Optional[str] = None, - user_identifier_key: Optional[str] = None, - on_response: Optional[Callable] = None + user_identifier_key: Optional[str] = None ) -> List[ConnectedAccount]: json_payload = {} @@ -56,9 +55,6 @@ def list( res = self.client.post("/connected_accounts/list", json=json_payload) - if on_response is not None: - on_response(res) - return [ConnectedAccount.from_dict(item) for item in res["connected_accounts"]] def update( From da984d17127c7041489160400884352dcf4a4b88 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Wed, 16 Apr 2025 12:57:56 +0200 Subject: [PATCH 17/25] Update docstring --- seam/paginator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seam/paginator.py b/seam/paginator.py index 7281746..cb4eff2 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -35,7 +35,7 @@ def __init__( Args: request: The function to call to fetch a page of data. - http_client: The HTTP client used in the request. + http_client: The Seam HTTP client used in the request. params: Initial parameters to pass to the callable function. """ self._request = request From 61bf8b21455b02641bceee86f03a9f721e4363ea Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Thu, 17 Apr 2025 14:44:49 +0200 Subject: [PATCH 18/25] Move pagination class into a separate file --- seam/pagination.py | 10 ++++++++++ seam/paginator.py | 13 +------------ 2 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 seam/pagination.py diff --git a/seam/pagination.py b/seam/pagination.py new file mode 100644 index 0000000..52df67f --- /dev/null +++ b/seam/pagination.py @@ -0,0 +1,10 @@ +class Pagination: + def __init__( + self, + has_next_page: bool, + next_page_cursor: str | None, + next_page_url: str | None, + ): + self.has_next_page = has_next_page + self.next_page_cursor = next_page_cursor + self.next_page_url = next_page_url diff --git a/seam/paginator.py b/seam/paginator.py index cb4eff2..5208fb3 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -1,18 +1,7 @@ from typing import Callable, Dict, Any, Tuple, Generator, List from .client import SeamHttpClient from niquests import Response, JSONDecodeError - - -class Pagination: - def __init__( - self, - has_next_page: bool, - next_page_cursor: str | None, - next_page_url: str | None, - ): - self.has_next_page = has_next_page - self.next_page_cursor = next_page_cursor - self.next_page_url = next_page_url +from .pagination import Pagination class Paginator: From c6df47b218e443f91d7b00c2ab1ff179348077cf Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Thu, 17 Apr 2025 14:46:29 +0200 Subject: [PATCH 19/25] Positional only --- seam/seam.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seam/seam.py b/seam/seam.py index c747800..348a084 100644 --- a/seam/seam.py +++ b/seam/seam.py @@ -91,7 +91,7 @@ def __init__( Routes.__init__(self, client=self.client, defaults=self.defaults) def create_paginator( - self, request: Callable, params: Optional[Dict[str, Any]] = None + self, request: Callable, params: Optional[Dict[str, Any]] = None, / ) -> Paginator: """ Creates a Paginator instance for iterating through list endpoints. @@ -113,7 +113,7 @@ def create_paginator( >>> for connected_account in connected_accounts_paginator.flatten(): >>> print(connected_account.account_type_display_name) """ - return Paginator(request, self.client, params) + return Paginator(self.client, request, params) @classmethod def from_api_key( From 42ad7de77a152f599d295c940aa7b432a54c74d4 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Thu, 17 Apr 2025 14:46:49 +0200 Subject: [PATCH 20/25] Change param order, use better naming --- seam/paginator.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/seam/paginator.py b/seam/paginator.py index 5208fb3..1753a78 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -15,8 +15,8 @@ class Paginator: def __init__( self, + client: SeamHttpClient, request: Callable, - http_client: SeamHttpClient, params: Dict[str, Any] = None, ): """ @@ -28,17 +28,17 @@ def __init__( params: Initial parameters to pass to the callable function. """ self._request = request - self._http_client = http_client + 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._http_client.hooks["response"].append( + self.client.hooks["response"].append( lambda response: self._cache_pagination(response, self._FIRST_PAGE) ) data = self._request(**self._params) - self._http_client.hooks["response"].pop() + self.client.hooks["response"].pop() pagination = self._pagination_cache.get(self._FIRST_PAGE) @@ -54,11 +54,11 @@ def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None "page_cursor": next_page_cursor, } - self._http_client.hooks["response"].append( + self.client.hooks["response"].append( lambda response: self._cache_pagination(response, next_page_cursor) ) data = self._request(**params) - self._http_client.hooks["response"].pop() + self.client.hooks["response"].pop() pagination = self._pagination_cache.get(next_page_cursor) From 760e80a662202514dfa05ae3a820221a6707f602 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Thu, 17 Apr 2025 15:01:59 +0200 Subject: [PATCH 21/25] Add readme section, rename Paginator to SeamPaginator --- README.rst | 104 ++++++++++++++++++++++++++++++++++++++++++++++ seam/paginator.py | 2 +- seam/seam.py | 6 +-- 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 1743987..952f178 100644 --- a/README.rst +++ b/README.rst @@ -257,6 +257,110 @@ For example: except SeamActionAttemptTimeoutError as e: print("Door took too long to unlock") +Pagination +~~~~~~~~~~ + +Some Seam API endpoints that return lists of resources support pagination. +Use the ``SeamPaginator`` class to fetch and process resources across multiple pages. + +Manually fetch pages with the nextPageCursor +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from seam import Seam + + seam = Seam() + + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20}) + + connected_accounts, pagination = paginator.first_page() + + if pagination.has_next_page: + more_connected_accounts, _ = paginator.next_page(pagination.next_page_cursor) + +Resume pagination +^^^^^^^^^^^^^^^^^ + +Get the first page on initial load and store the state (e.g., in memory or a file): + +.. code-block:: python + + import json + from seam import Seam + + seam = Seam() + + params = {"limit": 20} + paginator = seam.create_paginator(seam.connected_accounts.list, params) + + connected_accounts, pagination = paginator.first_page() + + # Example: Store state for later use (e.g., in a file or database) + pagination_state = { + "params": params, + "next_page_cursor": pagination.next_page_cursor, + "has_next_page": pagination.has_next_page, + } + # with open("pagination_state.json", "w") as f: + # with open("/tmp/seam_connected_accounts_list.json", "w") as f: + # json.dump(pagination_state, f) + +Get the next page at a later time using the stored state: + +.. code-block:: python + + import json + from seam import Seam + + seam = Seam() + + # Example: Load state from where it was stored + # with open("/tmp/seam_connected_accounts_list.json", "r") as f: + # pagination_state = json.load(f) + # Placeholder for loaded state: + pagination_state = { + "params": {"limit": 20}, + "next_page_cursor": "some_cursor_value", + "has_next_page": True, + } + + + if pagination_state.get("has_next_page"): + paginator = seam.create_paginator( + seam.connected_accounts.list, pagination_state["params"] + ) + more_connected_accounts, _ = paginator.next_page( + pagination_state["next_page_cursor"] + ) + +Iterate over all resources +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from seam import Seam + + seam = Seam() + + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20}) + + for account in paginator.flatten(): + print(account.account_type_display_name) + +Return all resources across all pages as a list +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + from seam import Seam + + seam = Seam() + + paginator = seam.create_paginator(seam.connected_accounts.list, {"limit": 20}) + + all_connected_accounts = paginator.flatten_to_list() + Interacting with Multiple Workspaces ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/seam/paginator.py b/seam/paginator.py index 1753a78..3a3cf19 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -4,7 +4,7 @@ from .pagination import Pagination -class Paginator: +class SeamPaginator: """ Handles pagination for API list endpoints. diff --git a/seam/seam.py b/seam/seam.py index 348a084..df49551 100644 --- a/seam/seam.py +++ b/seam/seam.py @@ -7,7 +7,7 @@ from .routes import Routes from .models import AbstractSeam from .client import SeamHttpClient -from .paginator import Paginator +from .paginator import SeamPaginator class Seam(AbstractSeam): @@ -92,7 +92,7 @@ def __init__( def create_paginator( self, request: Callable, params: Optional[Dict[str, Any]] = None, / - ) -> Paginator: + ) -> SeamPaginator: """ Creates a Paginator instance for iterating through list endpoints. @@ -113,7 +113,7 @@ def create_paginator( >>> for connected_account in connected_accounts_paginator.flatten(): >>> print(connected_account.account_type_display_name) """ - return Paginator(self.client, request, params) + return SeamPaginator(self.client, request, params) @classmethod def from_api_key( From 1ef305bff9c50a9c767e0d8366bb6120d78327fa Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Thu, 17 Apr 2025 13:02:39 +0000 Subject: [PATCH 22/25] ci: Generate code --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index 952f178..2e963f7 100644 --- a/README.rst +++ b/README.rst @@ -47,6 +47,16 @@ Contents * `Action Attempts `_ + * `Pagination `_ + + * `Manually fetch pages with the nextPageCursor `_ + + * `Resume pagination `_ + + * `Iterate over all resources `_ + + * `Return all resources across all pages as a list `_ + * `Interacting with Multiple Workspaces `_ * `Webhooks `_ From 3d9e5829b1b433d64735c34f0922d7c9ea347672 Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <10balian10@gmail.com> Date: Thu, 17 Apr 2025 15:07:39 +0200 Subject: [PATCH 23/25] Improve readme code snippets --- README.rst | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index 2e963f7..727c4f4 100644 --- a/README.rst +++ b/README.rst @@ -312,9 +312,8 @@ Get the first page on initial load and store the state (e.g., in memory or a fil "next_page_cursor": pagination.next_page_cursor, "has_next_page": pagination.has_next_page, } - # with open("pagination_state.json", "w") as f: - # with open("/tmp/seam_connected_accounts_list.json", "w") as f: - # json.dump(pagination_state, f) + with open("/tmp/seam_connected_accounts_list.json", "w") as f: + json.dump(pagination_state, f) Get the next page at a later time using the stored state: @@ -326,15 +325,8 @@ Get the next page at a later time using the stored state: seam = Seam() # Example: Load state from where it was stored - # with open("/tmp/seam_connected_accounts_list.json", "r") as f: - # pagination_state = json.load(f) - # Placeholder for loaded state: - pagination_state = { - "params": {"limit": 20}, - "next_page_cursor": "some_cursor_value", - "has_next_page": True, - } - + with open("/tmp/seam_connected_accounts_list.json", "r") as f: + pagination_state = json.load(f) if pagination_state.get("has_next_page"): paginator = seam.create_paginator( From 7881fbb2f4ae7a9137e4800258524f84731dc48e Mon Sep 17 00:00:00 2001 From: Andrii Balitskyi <84702959+andrii-balitskyi@users.noreply.github.com> Date: Fri, 18 Apr 2025 09:47:22 +0200 Subject: [PATCH 24/25] Update seam/paginator.py Co-authored-by: Evan Sosenko --- seam/paginator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seam/paginator.py b/seam/paginator.py index 3a3cf19..bfb7290 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -44,7 +44,7 @@ def first_page(self) -> Tuple[List[Any], Pagination | None]: return data, pagination - def next_page(self, next_page_cursor: str) -> Tuple[List[Any], Pagination | None]: + def next_page(self, next_page_cursor: str, /) -> Tuple[List[Any], Pagination | None]: """Fetches the next page of results using a cursor.""" if not next_page_cursor: raise ValueError("Cannot get the next page with a null next_page_cursor.") From 43afa8198348e398d8ad6875ca9e42a207d3e026 Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Fri, 18 Apr 2025 07:47:55 +0000 Subject: [PATCH 25/25] ci: Format code --- seam/paginator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/seam/paginator.py b/seam/paginator.py index bfb7290..fedb86d 100644 --- a/seam/paginator.py +++ b/seam/paginator.py @@ -44,7 +44,9 @@ def first_page(self) -> Tuple[List[Any], Pagination | None]: return data, pagination - def next_page(self, next_page_cursor: str, /) -> Tuple[List[Any], Pagination | None]: + def next_page( + self, next_page_cursor: str, / + ) -> Tuple[List[Any], Pagination | None]: """Fetches the next page of results using a cursor.""" if not next_page_cursor: raise ValueError("Cannot get the next page with a null next_page_cursor.")