Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
# Change Log
## [4.63.0](https://github.com/plivo/plivo-python/tree/v4.63.0) (2026-09-23)
**Bug Fix - Auto-pagination under cursor pagination**
- Fixed `__iter__` looping forever on Call and Recording list endpoints when the API ignores a client-supplied `offset`. Iteration now follows `meta.next`, reading whichever of `cursor=` or `offset=` it carries, and falls back to the offset walk for responses without `meta`.
- Added optional `cursor` parameter to `calls.list()` and `recordings.list()`. Appended as a trailing optional argument; existing `offset` callers are unaffected.

## [4.62.0](https://github.com/plivo/plivo-python/tree/v4.62.0) (2026-07-28)
**Feature - Toll-free verification terms, privacy, opt-in and help fields**
- Added optional `terms_and_conditions_link`, `privacy_policy_link`, `optin_message` and `help_message` parameters to the toll-free verification create and update methods
Expand Down
112 changes: 106 additions & 6 deletions plivo/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,76 @@

from plivo.exceptions import InvalidRequestError

try:
from urllib.parse import parse_qs, urlparse
except ImportError:
from urlparse import parse_qs, urlparse

DEFAULT_PAGE_LIMIT = 20

# Outcomes of _next_page_params() that aren't a params dict.
_END_OF_PAGES = object() # meta says there is no next page
_NO_PAGE_HINT = object() # response carried no meta to follow


def _attr(obj, key):
"""Read a key off a response fragment, dict or ResponseObject alike."""
if isinstance(obj, dict):
return obj.get(key)
# getattr, not []: ResponseObject.__getitem__ falls back to
# self.objects on a miss, which is not what we want here.
return getattr(obj, key, None)


def _next_page_params(response, limit):
"""Derive the next page's request params from a list response's meta.

meta.next is a relative URL whose query string carries either an
opaque cursor or a plain offset, depending on which pagination mode
the deployment happens to be serving:

'/v1/.../Call/?limit=20&cursor=<opaque>' cursor / hybrid
'/v1/.../Call/?limit=20&offset=20' offset (legacy)

Reading whichever param is present is what lets one walk stay correct
in all three modes without the SDK knowing which one is live.

Returns a params dict, or _END_OF_PAGES / _NO_PAGE_HINT.
"""
meta = _attr(response, 'meta')
if meta is None:
return _NO_PAGE_HINT

next_url = _attr(meta, 'next')
if not next_url:
return _END_OF_PAGES

query = parse_qs(urlparse(str(next_url)).query)

next_limit = query.get('limit', [None])[0]
if next_limit is not None:
try:
limit = int(next_limit)
except ValueError:
pass

cursor = query.get('cursor', [None])[0]
if cursor:
# A cursor supersedes offset, and offset must not ride along with
# it: under hybrid the server still applies a supplied offset,
# which would pin the walk to page 1 forever.
return {'limit': limit, 'cursor': cursor, 'offset': None}

offset = query.get('offset', [None])[0]
if offset is not None:
try:
return {'limit': limit, 'offset': int(offset)}
except ValueError:
return _END_OF_PAGES

# meta.next is there but carries neither param we understand.
return _NO_PAGE_HINT


class Meta:
def __init__(self):
Expand Down Expand Up @@ -338,20 +408,50 @@ def __init__(self, client, **kwargs):
self.client = client

def __iter__(self):
if not getattr(self, 'list') or not self.__class__._iterable:
"""Walks every page of this resource, one record at a time.

Follows meta.next rather than computing its own offsets, so the
walk stays correct whether the deployment applies offset, ignores
it in favour of cursors, or serves the hybrid of the two. Falls
back to incrementing offset only for responses that carry no meta.
"""
if not getattr(self, 'list', None) or not self.__class__._iterable:
raise NotImplementedError(
'list is not supported for this resource')

def gen():
limit = 20
offset = 0
limit = DEFAULT_PAGE_LIMIT
params = {'limit': limit, 'offset': 0}

while True:
response = self.list(limit=limit, offset=offset)
if not response.objects:
response = self.list(**params)
if not _attr(response, 'objects'):
return

for item in response:
yield item
offset += limit

next_params = _next_page_params(response, limit)

if next_params is _END_OF_PAGES:
return

if next_params is _NO_PAGE_HINT:
# No usable meta. If we were walking by offset we can
# carry on and stop on the first empty page; if we
# were following a cursor there is nothing to follow.
if params.get('offset') is None:
return
next_params = dict(params)
next_params['offset'] += limit

if next_params == params:
# Never re-request the page we just consumed. A server
# that ignores our paging params would otherwise hand
# back page 1 forever.
return

params = next_params
limit = params.get('limit') or limit

return gen()
4 changes: 3 additions & 1 deletion plivo/resources/calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ def create(self,
],
callback_url=[optional(is_url())],
callback_method=[optional(of_type(six.text_type))],
cursor=[optional(of_type(six.text_type))],
)
def list(self,
subaccount=None,
Expand All @@ -262,7 +263,8 @@ def list(self,
hangup_cause_code=None,
hangup_source=None,
callback_url=None,
callback_method=None
callback_method=None,
cursor=None
):
# Adding if else block because if we are fetching response without callback_url then response will be of type
# ListResponseObject, if passing callback_url then will be of type
Expand Down
4 changes: 3 additions & 1 deletion plivo/resources/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class Recordings(PlivoResourceInterface):
],
callback_url=[optional(is_url())],
callback_method=[optional(of_type(six.text_type))],
cursor=[optional(of_type(six.text_type))],
)
def list(self,
subaccount=None,
Expand All @@ -71,7 +72,8 @@ def list(self,
conference_name=None,
mpc_name=None,
conference_uuid=None,
mpc_uuid=None):
mpc_uuid=None,
cursor=None):

if subaccount:
if isinstance(subaccount, Subaccount):
Expand Down
2 changes: 1 addition & 1 deletion plivo/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# -*- coding: utf-8 -*-
__version__ = '4.62.0'
__version__ = '4.63.0'
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

setup(
name='plivo',
version='4.62.0',
version='4.63.0',
description='A Python SDK to make voice calls & send SMS using Plivo and to generate Plivo XML',
long_description=long_description,
url='https://github.com/plivo/plivo-python',
Expand Down
Loading
Loading