1111
1212from apify_client import ApifyClient , ApifyClientAsync
1313from apify_client import _models as _models_module
14+ from apify_client ._models import ListOfRequests
15+ from apify_client ._pagination import (
16+ get_cursor_iterator ,
17+ get_cursor_iterator_async ,
18+ get_items_iterator ,
19+ get_items_iterator_async ,
20+ )
1421from apify_client ._resource_clients import (
1522 ActorCollectionClient ,
1623 ActorCollectionClientAsync ,
113120)
114121
115122# Outer wrappers that embed a relaxed list model via `.data`. Their compiled schema pins the inner's schema at
116- # construction time, so they need a forced rebuild to pick up the relaxation. The wrappers themselves are not mutated —
123+ # construction time, so they need a forced rebuild to pick up the relaxation. The wrappers themselves are not mutated -
117124# their own field annotations stay as-is.
118125_REBUILT_RESPONSE_WRAPPERS = (
119126 'ListOfActorsInStoreResponse' ,
@@ -138,9 +145,9 @@ def _relax_item_validation() -> Any:
138145 """Relax only the element type of `items` on paginated list models for the test run.
139146
140147 Pagination tests feed synthetic `{'id': N}` items that don't satisfy the real API schemas (`ActorShort`,
141- `BuildShort`, `Request`, `EnvVar`, … ). Instead of bypassing validation wholesale, each inner `ListOf*` model has its
142- `items` field swapped to `list[dict]` and rebuilt. Outer `.data` wrapping and every pagination-metadata field remain
143- validated.
148+ `BuildShort`, `Request`, `EnvVar`, ... ). Instead of bypassing validation wholesale, each inner `ListOf*` model
149+ has its `items` field swapped to `list[dict]` and rebuilt. Outer `.data` wrapping and every pagination-metadata
150+ field remain validated.
144151 """
145152 relaxed_field = FieldInfo .from_annotation (list [dict ])
146153 originals : dict [type [BaseModel ], FieldInfo ] = {}
@@ -171,7 +178,7 @@ def create_items(start: int, end: int, step: int | None = None) -> list[dict[str
171178
172179
173180def _is_true (value : str | None ) -> bool :
174- """Match the `'true'` wire form produced by the client's bool→ string serialization."""
181+ """Match the `'true'` wire form produced by the client's bool-> string serialization."""
175182 return value == 'true'
176183
177184
@@ -235,7 +242,7 @@ def _handle_cursor_pagination(request: Request) -> Response:
235242 """Serve a cursor-paginated Apify API response for KVS keys and RQ requests.
236243
237244 Holds 2500 synthetic items whose integer `id` equals their position. Each page is capped at 1000 items. KVS uses
238- `exclusiveStartKey`; RQ uses the opaque `cursor`. Both values encode the last-seen item id as a string — the
245+ `exclusiveStartKey`; RQ uses the opaque `cursor`. Both values encode the last-seen item id as a string - the
239246 next page starts at id + 1.
240247 """
241248 params = request .args
@@ -617,3 +624,64 @@ async def test_rq_list_requests_iterable_async(
617624 client : RequestQueueClientAsync = _CLIENT_FACTORIES [client_name ](_make_async_client (pagination_server ))
618625 returned_items = [dict (item ) async for item in client .iterate_requests (** inputs )]
619626 assert returned_items == expected_items
627+
628+
629+ class FakeOffsetPage :
630+ """Offset-paginated page whose `count` (items scanned) may exceed `len(items)` when filters drop items."""
631+
632+ def __init__ (self , items : list [dict [str , int ]], count : int ) -> None :
633+ self .items = items
634+ self .count = count
635+
636+
637+ def test_items_iterator_continues_past_fully_filtered_page () -> None :
638+ """A fully-filtered page (`items=[]`, `count>0`) must not stop the offset iterator while more data was scanned."""
639+ pages = {
640+ 0 : FakeOffsetPage (items = [], count = 1000 ),
641+ 1000 : FakeOffsetPage (items = [{'id' : 1 }, {'id' : 2 }], count = 2 ),
642+ }
643+
644+ def callback (* , offset : int | None = None , ** _kwargs : object ) -> FakeOffsetPage :
645+ return pages .get (offset or 0 , FakeOffsetPage (items = [], count = 0 ))
646+
647+ assert list (get_items_iterator (callback , chunk_size = 1000 )) == [{'id' : 1 }, {'id' : 2 }]
648+
649+
650+ async def test_items_iterator_async_continues_past_fully_filtered_page () -> None :
651+ """A fully-filtered page (`items=[]`, `count>0`) must not stop the async offset iterator while more was scanned."""
652+ pages = {
653+ 0 : FakeOffsetPage (items = [], count = 1000 ),
654+ 1000 : FakeOffsetPage (items = [{'id' : 1 }, {'id' : 2 }], count = 2 ),
655+ }
656+
657+ async def callback (* , offset : int | None = None , ** _kwargs : object ) -> FakeOffsetPage :
658+ return pages .get (offset or 0 , FakeOffsetPage (items = [], count = 0 ))
659+
660+ assert [item async for item in get_items_iterator_async (callback , chunk_size = 1000 )] == [{'id' : 1 }, {'id' : 2 }]
661+
662+
663+ def test_cursor_iterator_continues_past_fully_filtered_page () -> None :
664+ """A fully-filtered page (`items=[]`) with a live cursor must not stop the cursor iterator."""
665+ pages = {
666+ None : ListOfRequests (items = [], limit = 1000 , next_cursor = 'c1' ),
667+ 'c1' : ListOfRequests (items = [{'id' : 1 }, {'id' : 2 }], limit = 1000 , next_cursor = None ),
668+ }
669+
670+ def callback (* , cursor : str | None = None , ** _kwargs : object ) -> ListOfRequests :
671+ return pages [cursor ]
672+
673+ assert list (get_cursor_iterator (callback , chunk_size = 1000 )) == [{'id' : 1 }, {'id' : 2 }]
674+
675+
676+ async def test_cursor_iterator_async_continues_past_fully_filtered_page () -> None :
677+ """A fully-filtered page (`items=[]`) with a live cursor must not stop the async cursor iterator."""
678+ pages = {
679+ None : ListOfRequests (items = [], limit = 1000 , next_cursor = 'c1' ),
680+ 'c1' : ListOfRequests (items = [{'id' : 1 }, {'id' : 2 }], limit = 1000 , next_cursor = None ),
681+ }
682+
683+ async def callback (* , cursor : str | None = None , ** _kwargs : object ) -> ListOfRequests :
684+ return pages [cursor ]
685+
686+ collected = [item async for item in get_cursor_iterator_async (callback , chunk_size = 1000 )]
687+ assert collected == [{'id' : 1 }, {'id' : 2 }]
0 commit comments