Source code for dnaerys._pagination
"""Paginated variant query API for the Dnaerys client library.
``PaginatedQuery`` manages an internal buffer via ``collections.deque``,
refills transparently from the server, and serves fixed-size pages to the
caller. ``skip`` and ``limit`` become invisible implementation details.
"""
from __future__ import annotations
from collections import deque
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from types import TracebackType
from dnaerys._stream import VariantStream
from dnaerys._types import ResponseMetadata, Variant
[docs]
@dataclass(frozen=True, slots=True)
class Page:
"""A single page of variant results from a paginated query.
Parameters
----------
variants : tuple[Variant, ...]
The variants in this page.
page_number : int
1-based page number.
"""
variants: tuple[Variant, ...]
page_number: int
[docs]
class PaginatedQuery:
"""Iterator that serves fixed-size pages of variants from a Dnaerys query.
Manages an internal ``collections.deque`` buffer, refilling it
transparently from the server via the provided *fetch* callable.
Callers see clean, fixed-size pages without needing to know about
``skip``/``limit`` or cluster topology.
Parameters
----------
fetch : Callable[[int, int], VariantStream]
A callable that takes ``(skip, limit)`` and returns a
``VariantStream``.
page_size : int
Number of variants per page (last page may be shorter).
buffer_size : int
Per-ring window requested per server round-trip. Must be ``<=`` the
server per-ring cap so every owner ring is walked without gaps; it may
be smaller than *page_size* (a page is assembled from as many
round-trips as needed).
Raises
------
ValueError
If *page_size* < 1 or *buffer_size* < 1.
"""
[docs]
def __init__(
self,
fetch: Callable[[int, int], VariantStream],
*,
page_size: int,
buffer_size: int = 5000,
) -> None:
if page_size < 1:
raise ValueError("page_size must be >= 1")
if buffer_size < 1:
raise ValueError("buffer_size must be >= 1")
self._fetch = fetch
self._page_size = page_size
self._buffer_size = buffer_size
self._buffer: deque[Variant] = deque()
self._offset: int = 0
self._page_number: int = 0
self._exhausted: bool = False
self._total_fetched: int = 0
# Metadata accumulators (same pattern as VariantStream)
self._incomplete_cluster: bool = False
self._affected: bool = False
self._elapsed_ms: int = 0
self._elapsed_db_ms: int = 0
self._node_ids: set[str] = set()
@property
def page_size(self) -> int:
"""Number of variants per page."""
return self._page_size
@property
def buffer_size(self) -> int:
"""Number of variants requested per server round-trip."""
return self._buffer_size
@property
def total_fetched(self) -> int:
"""Total number of variants served across all pages so far."""
return self._total_fetched
@property
def has_more(self) -> bool:
"""Whether more pages may be available.
Returns ``True`` if the buffer contains variants or the server has
not yet been confirmed exhausted. Returns ``False`` only after
iteration is complete and the buffer is empty.
.. note::
Before the first :meth:`next_page` call, this property returns
``True`` even if the query matches zero variants — no server
call has been made yet, so exhaustion is unknown. The first
:meth:`next_page` call triggers the first server round-trip;
if it returns no variants, :exc:`StopIteration` is raised
immediately and ``has_more`` becomes ``False``.
"""
return not (self._exhausted and len(self._buffer) == 0)
@property
def metadata(self) -> ResponseMetadata:
"""Accumulated metadata from all server round-trips so far."""
return ResponseMetadata(
elapsed_ms=self._elapsed_ms,
elapsed_db_ms=self._elapsed_db_ms,
node_id=",".join(sorted(self._node_ids)) if self._node_ids else "",
incomplete_cluster=self._incomplete_cluster,
affected=self._affected,
)
def _accumulate_metadata(self, stream_metadata: ResponseMetadata) -> None:
self._incomplete_cluster = self._incomplete_cluster or stream_metadata.incomplete_cluster
self._affected = self._affected or stream_metadata.affected
self._elapsed_ms = max(self._elapsed_ms, stream_metadata.elapsed_ms)
self._elapsed_db_ms = max(self._elapsed_db_ms, stream_metadata.elapsed_db_ms)
if stream_metadata.node_id:
for part in stream_metadata.node_id.split(","):
part = part.strip()
if part:
self._node_ids.add(part)
def _refill(self) -> None:
stream = self._fetch(self._offset, self._buffer_size)
new_variants = stream.to_list()
self._accumulate_metadata(stream.metadata)
if not new_variants:
self._exhausted = True
return
self._buffer.extend(new_variants)
self._offset += self._buffer_size
[docs]
def next_page(self) -> Page:
"""Return the next page of variants.
Raises
------
StopIteration
When no more variants are available.
"""
while len(self._buffer) < self._page_size and not self._exhausted:
self._refill()
if not self._buffer:
raise StopIteration
count = min(self._page_size, len(self._buffer))
page_variants = [self._buffer.popleft() for _ in range(count)]
self._page_number += 1
self._total_fetched += count
return Page(variants=tuple(page_variants), page_number=self._page_number)
def __iter__(self) -> Iterator[Page]:
return self
def __next__(self) -> Page:
return self.next_page()
def __enter__(self) -> PaginatedQuery:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
self._buffer.clear()
self._exhausted = True