Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8a8bc0f
first pass at adding cursor paging
Jan 9, 2026
de1e246
draft
Jan 13, 2026
34084ba
first draft at parallel partitioning
Jan 13, 2026
19929f1
code clean up
Jan 14, 2026
8c70514
move partitioning logic
Jan 14, 2026
411628f
change default
Jan 15, 2026
8f4b98e
add condition to skip first fetch when partitioning
Jan 20, 2026
a2dfb6c
flatten final return list
Jan 21, 2026
01d36fd
revert default paging method
Jan 22, 2026
c9f6397
add docs
Jan 22, 2026
ef7282d
add check for endpoint type since cursor paging does not support dele…
Jan 22, 2026
cf0b236
remove partitioning for now
Jan 26, 2026
a495ec0
decouple and refactor get_pages() into 2 smaller pagination generators
Jan 26, 2026
36157dc
default to cursor paging for `get_rows()`
Jan 26, 2026
2f0050d
fall back on reverse-offset if ods version not compatible with cursor…
Jan 27, 2026
68401f2
fall back on reverse-offset if incompatible with cursor paging
Jan 27, 2026
b3b708c
remove default step_change_version arg
Jan 27, 2026
d622074
undo get()
Jan 27, 2026
749cfc0
remove unneeded code
Jan 27, 2026
8672da0
move fallbacks to `get_rows()`
Jan 27, 2026
db06877
debug
Jan 28, 2026
493f998
reverse-offset as default
Jan 28, 2026
776c3ba
code clean up
Jan 29, 2026
b1a83ec
Minor cleanup of whitespace and logging.
jayckaiser Feb 3, 2026
aca665f
Implement get_pages instead of get_pages_cursor in composites.
jayckaiser Feb 3, 2026
1376c31
Label composite-offset pagination scheme with correct method.
jayckaiser Feb 3, 2026
f2efff8
Merge branch 'main' into feature/cursor_paging
jayckaiser Mar 24, 2026
75c42b9
Use defined logger in EdFiEndpoint instead of default.
jayckaiser Mar 24, 2026
76052ab
Minor cleanup.
jayckaiser Mar 24, 2026
8762b2c
Merge branch 'feature/cursor_paging' of https://github.com/edanalytic…
Apr 13, 2026
e6b684e
remove attribute
Apr 13, 2026
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
65 changes: 61 additions & 4 deletions edfi_api_client/edfi_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import requests

from functools import partial
from typing import Iterator, List, Optional, Tuple, Union

from edfi_api_client.edfi_params import EdFiParams
Expand All @@ -11,6 +12,7 @@
if TYPE_CHECKING:
from edfi_api_client.edfi_client import EdFiClient


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -165,10 +167,31 @@ def get_rows(self,
:param max_wait:
:return:
"""
paged_result_iter = self.get_pages(
# Always default to cursor-pagination; fall back to reverse-offset paging if unsupported
paginator = self.get_pages_cursor

## Check ODS version compatibility for cursor paging
ods_version = self.client.get_ods_version()
if not ods_version or tuple(map(int, ods_version.split(".")[:2])) < (7,3):
logger.warning(f"ODS version {ods_version} is incompatible with cursor-pagination (requires v7.3 or higher). Falling back to reverse-offset pagination...")
paginator = partial(self.get_pages,
reverse_paging=reverse_paging,
step_change_version=step_change_version,
change_version_step_size=change_version_step_size
)

## deletes/key_changes cannot be retrieved with cursor paging
if self.get_deletes or self.get_key_changes:
logger.warning(f"Cursor-pagination is unsupported in deletes/key_changes endpoints. Falling back to reverse-offset pagination...")
paginator = partial(self.get_pages,
reverse_paging=reverse_paging,
step_change_version=step_change_version,
change_version_step_size=change_version_step_size
)

paged_result_iter = paginator(
params=params,
page_size=page_size, reverse_paging=reverse_paging,
step_change_version=step_change_version, change_version_step_size=change_version_step_size,
page_size=page_size,
**kwargs
)

Expand Down Expand Up @@ -220,9 +243,10 @@ def get_pages(self,

# Begin pagination-loop
while True:
logger.info(f"[Get {self.component}] Parameters: {paged_params}")

### GET from the API and yield the resulting JSON payload
paged_rows = self.get(params=paged_params, **kwargs)
paged_rows = self.client.session.get_response(self.url, params=paged_params, **kwargs).json()
logger.info(f"[Get {self.component}] Retrieved {len(paged_rows)} rows.")
yield paged_rows

Expand Down Expand Up @@ -261,6 +285,35 @@ def get_pages(self,
else:
logger.info(f"@ Paginating offset...")
paged_params.page_by_offset()


def get_pages_cursor(self,
*,
params: Optional[dict] = None, # Optional alternative params
page_size: int = 100,
**kwargs
) -> Iterator[List[dict]]:

# Override init params if passed
paged_params = EdFiParams(params or self.params).copy()
logger.info(f"[Paged Get {self.component}] Pagination Method: Cursor Paging")

# Begin pagination loop
while True:
logger.info(f"[Get {self.component}] Parameters: {paged_params}")

result = self.client.session.get_response(self.url, params=paged_params, **kwargs)
Comment thread
jayckaiser marked this conversation as resolved.
paged_rows = result.json()
logger.info(f"[Get {self.component}] Retrieved {len(paged_rows)} rows")
yield paged_rows

logger.info(f"[Paged Get {self.component}] @ Checking next page token...")
if not (page_token := result.headers.get("Next-Page-Token")):
logger.info(f"[Paged Get {self.component}] @ Retrieved empty page token. Ending pagination.")
break

paged_params.page_by_token(page_token=page_token, page_size=page_size)



def get_total_count(self, *, params: Optional[dict] = None, **kwargs) -> int:
Expand Down Expand Up @@ -404,6 +457,10 @@ def get_total_count(self, *args, **kwargs):
:return:
"""
raise NotImplementedError("Total counts have not been implemented in Ed-Fi composites!")

def get_pages_cursor(self, *args, **kwargs):
logger.info(f"Composite endpoints are incompatible with cursor-pagination. Falling back to offset pagination...")
yield from self.get_pages(*args, **kwargs)

def get_pages(self, *, params: Optional[dict] = None, page_size: int = 100, **kwargs) -> Iterator[List[dict]]:
"""
Expand Down
23 changes: 23 additions & 0 deletions edfi_api_client/edfi_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def __init__(self,
# These parameters are only used during pagination. They must be explicitly initialized.
self.page_size = None
self.change_version_step_size = None
self.page_token = None
self.number = None
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this number attribute used?



def copy(self) -> 'EdFiParams':
Expand Down Expand Up @@ -192,3 +194,24 @@ def reverse_page_by_offset(self):

if self['offset'] < 0:
raise StopIteration


def page_by_token(self, page_token: Optional[str], page_size: int):
"""
Cursor paging behavior: page_token is required when page_size is specified.
- If page_token is None: first request, do NOT include page_size
- If page_token is present: include page_token and page_size

:param page_size:
:param page_token:
:return:
"""
self.page_size = page_size
self.page_token = page_token

if page_token is None:
self.pop("pageToken", None)
self.pop("pageSize", None)
else:
self["pageToken"] = self.page_token
self["pageSize"] = self.page_size