Skip to content

Commit

Permalink
refactor: improve async/sync pagination and rename pagination methods
Browse files Browse the repository at this point in the history
  • Loading branch information
matsk-sinch committed Feb 21, 2025
1 parent 9aad89a commit 482b1f8
Show file tree
Hide file tree
Showing 13 changed files with 62 additions and 124 deletions.
64 changes: 28 additions & 36 deletions sinch/core/pagination.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from abc import ABC, abstractmethod
from collections import namedtuple
from typing import Generic
from sinch.domains.numbers.models.numbers import BM
from sinch.core.types import BM


class PageIterator:
def __init__(self, paginator, yield_first_page=False):
self.paginator = paginator
self.yield_first_page = yield_first_page
# If yielding the first page, set started to False
self.started = not yield_first_page

Expand All @@ -27,25 +26,25 @@ def __next__(self):


class AsyncPageIterator:
def __init__(self, paginator):
def __init__(self, paginator, yield_first_page=False):
self.paginator = paginator
self.first_yield = True
self.started = not yield_first_page

def __aiter__(self):
return self

async def __anext__(self):
if self.first_yield:
self.first_yield = False
if not self.started:
self.started = True
return self.paginator

if self.paginator.has_next_page:
next_paginator = await self.paginator.next_page()
if next_paginator:
self.paginator = next_paginator
return self.paginator

raise StopAsyncIteration
else:
raise StopAsyncIteration


class Paginator(ABC, Generic[BM]):
Expand Down Expand Up @@ -82,8 +81,9 @@ def content(self):
def iterator(self):
pass

@abstractmethod
def auto_paging_iter(self):
# TODO: Make get_content() method abstract in Parent class as we implement in the other domains:
# - Refactor pydantic models in other domains to have a content property.
def get_content(self):
pass

@abstractmethod
Expand Down Expand Up @@ -134,7 +134,7 @@ async def next_page(self):
return self

def auto_paging_iter(self):
return AsyncPageIterator(self)
return AsyncPageIterator(self, yield_first_page=True)

@classmethod
async def _initialize(cls, sinch, endpoint):
Expand All @@ -147,7 +147,6 @@ class TokenBasedPaginator(Paginator[BM]):

def __init__(self, sinch, endpoint, yield_first_page=False, result=None):
super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint))
self.yield_first_page = yield_first_page

def content(self) -> list[BM]:
return getattr(self.result, "content", [])
Expand All @@ -162,10 +161,6 @@ def next_page(self):

return self.__class__(self._sinch, self.endpoint, result=next_result)

def auto_paging_iter(self):
"""Returns an iterator for automatic pagination."""
return PageIterator(self, yield_first_page=True)

def iterator(self):
"""Iterates over individual items across all pages."""
paginator = self
Expand All @@ -177,13 +172,13 @@ def iterator(self):
break
paginator = next_page_instance

def list(self):
def get_content(self):
"""Returns structured pagination metadata along with the first page's content (sync)."""
next_page_instance = self.next_page()
return self._list(next_page_instance, sync=True)
return self._get_content(next_page_instance, sync=True)

def _list(self, next_page_instance, sync=True):
"""Core logic for `list()`, shared between sync and async versions."""
def _get_content(self, next_page_instance, sync=True):
"""Core logic for `get_content()`, shared between sync and async versions."""
PagedListResponse = namedtuple(
"PagedResponse", ["result", "has_next_page", "next_page_info", "next_page"]
)
Expand All @@ -209,10 +204,10 @@ def _list(self, next_page_instance, sync=True):
def _get_next_page_wrapper(self, next_page_instance, sync):
"""Returns a function for fetching the next page."""
if sync:
return lambda: next_page_instance.list() if next_page_instance else None
return lambda: next_page_instance.get_content() if next_page_instance else None
else:
async def async_next_page_wrapper():
return await next_page_instance.list() if next_page_instance else None
return await next_page_instance.get_content() if next_page_instance else None
return async_next_page_wrapper

def _calculate_next_page(self):
Expand All @@ -235,20 +230,7 @@ async def next_page(self):
self.endpoint.request_data.page_token = self.result.next_page_token
next_result = await self._sinch.configuration.transport.request(self.endpoint)

return AsyncTokenBasedPaginator(self._sinch, self.endpoint, result=next_result)

def auto_paging_iter(self):
return AsyncPageIterator(self)

@classmethod
async def _initialize(cls, sinch, endpoint):
result = await sinch.configuration.transport.request(endpoint)
return cls(sinch, endpoint, result=result)

async def list(self):
"""Returns structured pagination metadata"""
next_page_instance = await self.next_page()
return self._list(next_page_instance, sync=False)
return self.__class__(self._sinch, self.endpoint, result=next_result)

async def iterator(self):
"""Iterates asynchronously over individual items across all pages."""
Expand All @@ -261,3 +243,13 @@ async def iterator(self):
if not next_page_instance:
break
paginator = next_page_instance

async def get_content(self):
"""Returns structured pagination metadata"""
next_page_instance = await self.next_page()
return self._get_content(next_page_instance, sync=False)

@classmethod
async def _initialize(cls, sinch, endpoint):
result = await sinch.configuration.transport.request(endpoint)
return cls(sinch, endpoint, yield_first_page=False, result=result)
4 changes: 4 additions & 0 deletions sinch/core/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from typing import TypeVar
from pydantic import BaseModel

BM = TypeVar("BM", bound=BaseModel)
10 changes: 5 additions & 5 deletions sinch/domains/numbers/active_numbers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def list(
number_type: NumberTypeValues,
number_pattern: Optional[StrictStr] = None,
number_search_pattern: Optional[NumberSearchPatternTypeValues] = None,
capability: Optional[CapabilityTypeValuesList] = None,
capabilities: Optional[CapabilityTypeValuesList] = None,
page_size: Optional[StrictInt] = None,
page_token: Optional[StrictStr] = None,
order_by: Optional[OrderByValues] = None,
Expand All @@ -43,7 +43,7 @@ def list(
number_pattern (Optional[StrictStr]): Specific sequence of digits to search for.
number_search_pattern (Optional[NumberSearchPatternTypeValues]):
Pattern to apply (e.g., "START", "CONTAINS", "END").
capability (Optional[CapabilityTypeValuesList]): Capabilities required for the number.
capabilities (Optional[CapabilityTypeValuesList]): Capabilities required for the number.
(e.g., ["SMS", "VOICE"])
page_size (StrictInt): Maximum number of items to return.
page_token (Optional[StrictStr]): Token for the next page of results.
Expand All @@ -63,7 +63,7 @@ def list(
region_code=region_code,
number_type=number_type,
page_size=page_size,
capabilities=capability,
capabilities=capabilities,
number_pattern=number_pattern,
number_search_pattern=number_search_pattern,
page_token=page_token,
Expand Down Expand Up @@ -139,7 +139,7 @@ async def list(
number_type: StrictStr,
number_pattern: Optional[StrictStr] = None,
number_search_pattern: Optional[NumberSearchPatternTypeValues] = None,
capability: Optional[CapabilityTypeValuesList] = None,
capabilities: Optional[CapabilityTypeValuesList] = None,
page_size: Optional[StrictInt] = None,
page_token: Optional[StrictStr] = None,
order_by: Optional[OrderByValues] = None,
Expand All @@ -153,7 +153,7 @@ async def list(
region_code=region_code,
number_type=number_type,
page_size=page_size,
capabilities=capability,
capabilities=capabilities,
number_pattern=number_pattern,
number_search_pattern=number_search_pattern,
page_token=page_token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@


class ListActiveNumbersEndpoint(NumbersEndpoint):
"""
Endpoint to list all active numbers for a project.
"""
ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers"
HTTP_METHOD = HTTPMethods.GET.value
HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value
Expand All @@ -18,9 +21,6 @@ def __init__(self, project_id: str, request_data: ListActiveNumbersRequest):
def build_query_params(self) -> dict:
return self.request_data.model_dump(exclude_none=True, by_alias=True)

def request_body(self) -> str:
return ""

def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse:
super(ListActiveNumbersEndpoint, self).handle_response(response)
return self.process_response_model(response.body, ListActiveNumbersResponse)
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from sinch.core.enums import HTTPAuthentication, HTTPMethods
from sinch.core.models.http_response import HTTPResponse
from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint
Expand All @@ -16,6 +17,11 @@ class ActivateNumberEndpoint(NumbersEndpoint):
def __init__(self, project_id: str, request_data: ActivateNumberRequest):
super(ActivateNumberEndpoint, self).__init__(project_id, request_data)

def request_body(self) -> str:
# Convert the request data to a dictionary and remove None values
request_data = self.request_data.model_dump(by_alias=True, exclude_none=True)
return json.dumps(request_data)

def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse:
try:
super(ActivateNumberEndpoint, self).handle_response(response)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest):
def build_query_params(self) -> dict:
return self.request_data.model_dump(exclude_none=True, by_alias=True)

def request_body(self) -> str:
return ""

def handle_response(self, response: HTTPResponse) -> list[Number]:
"""
Processes the API response and maps it to a response model.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from sinch.core.models.http_response import HTTPResponse
from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint
from sinch.core.enums import HTTPAuthentication, HTTPMethods
Expand All @@ -17,6 +18,10 @@ def __init__(self, project_id: str, request_data: RentAnyNumberRequest):
super(RentAnyNumberEndpoint, self).__init__(project_id, request_data)
self.request_data = request_data

def request_body(self) -> str:
request_data = self.request_data.model_dump(by_alias=True, exclude_none=True)
return json.dumps(request_data)

def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse:
"""
Handles the response from the API call.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,6 @@ class SearchForNumberEndpoint(NumbersEndpoint):
def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest):
super(SearchForNumberEndpoint, self).__init__(project_id, request_data)

def build_query_params(self) -> dict:
return self.request_data.model_dump(exclude_none=True, by_alias=True)

def request_body(self) -> str:
return ""

def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse:
"""
Processes the API response and maps it to a response
Expand Down
8 changes: 3 additions & 5 deletions sinch/domains/numbers/endpoints/numbers_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import json
from abc import ABC
from typing import Type
from sinch.core.models.http_response import HTTPResponse
from sinch.core.endpoint import HTTPEndpoint
from sinch.core.types import BM
from sinch.domains.numbers.exceptions import NumbersException
from sinch.domains.numbers.models.numbers import BM, NotFoundError
from sinch.domains.numbers.models.numbers import NotFoundError


class NumbersEndpoint(HTTPEndpoint, ABC):
Expand Down Expand Up @@ -38,9 +38,7 @@ def request_body(self) -> str:
Returns:
str: The request body as a JSON string.
"""
# Convert the request data to a dictionary and remove None values
request_data = self.request_data.model_dump(by_alias=True, exclude_none=True)
return json.dumps(request_data)
return ""

def process_response_model(self, response_body: dict, response_model: Type[BM]) -> BM:
"""
Expand Down
5 changes: 2 additions & 3 deletions sinch/domains/numbers/models/numbers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Literal, Optional, TypeVar, Union
from pydantic import BaseModel, ConfigDict, conlist, Field, StrictBool, StrictInt, StrictStr
from typing import Annotated, Literal, Optional, Union
from pydantic import ConfigDict, conlist, Field, StrictBool, StrictInt, StrictStr
from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse

BM = TypeVar("BM", bound=BaseModel)

NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr]
CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1)
Expand Down
3 changes: 1 addition & 2 deletions tests/e2e/numbers/features/steps/numbers.steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from behave import given, when, then
from decimal import Decimal
from sinch import SinchClient
from sinch.core.pagination import TokenBasedPaginator
from sinch.domains.numbers.exceptions import NumberNotFoundException
from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse
from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse
Expand Down Expand Up @@ -230,4 +229,4 @@ def step_when_release_phone_number(context, phone_number):

@then('the response contains details about the phone number "{phone_number}" to be released')
def step_then_response_contains_released_number(context, phone_number):
pass # Placeholder
pass # Placeholder
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,4 @@ def test_list_active_numbers_response_expects_correct_mapping(test_data):

def test_list_active_numbers_response_expects_content_mapping(test_data):
response = ListActiveNumbersResponse(**test_data)
assert response.content == response.active_numbers
assert response.content == response.active_numbers
Loading

0 comments on commit 482b1f8

Please sign in to comment.