Skip to content

Commit c5968ab

Browse files
TexasCodingclaude
andcommitted
feat(paginators): expose max_pages on every public *_all() method
Plumb max_pages: int | None = None through all 19 public *_all() paginator methods (sync + async, 38 signatures) so callers can cap fetch depth from outside without subclassing the resource. Previously the cap existed only on the internal _list_all and silently defaulted to 1000 pages. - _list_all: accept max_pages=None (default 1000), positive int caps page fetches at that count. - _validate_max_pages: rejects max_pages <= 0 with ValueError at the public boundary; None and positive ints pass through. - Add "client_only" ExclusionKind for kwargs that have no wire counterpart (max_pages is purely a client-side knob). EXCLUSIONS registers max_pages for every *_all FQN so TestRequestParamDrift doesn't flag the new kwarg as REMOVE drift. - Tests cover the bare numeric cap (cap=1 / cap=3 with a fresh-cursor side effect so cursor-loop detection can't trip), empty-cursor termination (F-Q-17 regression), and ValueError on max_pages=0 at the public list_all boundary (markets). Closes #98 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 643b6c5 commit c5968ab

17 files changed

Lines changed: 498 additions & 47 deletions

kalshi/resources/_base.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ def _check_request_exclusive(request: Any, **kwargs: Any) -> None:
4444
)
4545

4646

47+
def _validate_max_pages(max_pages: int | None) -> None:
48+
"""Reject ``max_pages <= 0`` at the public ``*_all()`` boundary.
49+
50+
``None`` (use default cap) and positive integers are valid. Zero or
51+
negative would silently produce an empty iterator deep inside
52+
``_list_all``; surfacing the misuse here is friendlier.
53+
"""
54+
if max_pages is not None and max_pages <= 0:
55+
raise ValueError(
56+
f"max_pages must be positive or None, got {max_pages}"
57+
)
58+
59+
4760
def _join_tickers(value: list[str] | tuple[str, ...] | str | None) -> str | None:
4861
"""Serialize the `tickers` query param (spec: comma-joined string, not explode:true).
4962
@@ -151,7 +164,7 @@ def _list_all(
151164
items_key: str,
152165
*,
153166
params: dict[str, Any] | None = None,
154-
max_pages: int = 1000,
167+
max_pages: int | None = None,
155168
cursor_key: str = "cursor",
156169
) -> Iterator[T]:
157170
"""Auto-paginate through all pages, yielding items.
@@ -160,13 +173,19 @@ def _list_all(
160173
convention). ``cursor_key`` only affects how the response envelope
161174
is parsed.
162175
176+
``max_pages`` caps the number of pages fetched as a safety net for
177+
servers that never repeat a cursor but never return empty either.
178+
``None`` (default) means the built-in ceiling of 1000. Callers that
179+
need a tighter cap (e.g. a quick preview) pass it explicitly.
180+
163181
Raises ``KalshiError`` if a cursor value repeats, which indicates
164182
a server-side pagination bug that would otherwise cause the safety
165183
cap to silently issue ``max_pages`` duplicate requests.
166184
"""
185+
page_cap = 1000 if max_pages is None else max_pages
167186
current_params = dict(params) if params else {}
168187
seen_cursors: set[str] = set()
169-
for _ in range(max_pages):
188+
for _ in range(page_cap):
170189
page = self._list(
171190
path, model_cls, items_key,
172191
params=current_params, cursor_key=cursor_key,
@@ -264,15 +283,17 @@ async def _list_all(
264283
items_key: str,
265284
*,
266285
params: dict[str, Any] | None = None,
267-
max_pages: int = 1000,
286+
max_pages: int | None = None,
268287
cursor_key: str = "cursor",
269288
) -> AsyncIterator[T]:
270-
"""Async counterpart of ``SyncResource._list_all``. Raises
271-
``KalshiError`` on repeated cursor; see sync docstring.
289+
"""Async counterpart of ``SyncResource._list_all``. ``max_pages``
290+
semantics mirror the sync version (``None`` -> 1000 default).
291+
Raises ``KalshiError`` on repeated cursor; see sync docstring.
272292
"""
293+
page_cap = 1000 if max_pages is None else max_pages
273294
current_params = dict(params) if params else {}
274295
seen_cursors: set[str] = set()
275-
for _ in range(max_pages):
296+
for _ in range(page_cap):
276297
page = await self._list(
277298
path, model_cls, items_key,
278299
params=current_params, cursor_key=cursor_key,

kalshi/resources/communications.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
SyncResource,
2525
_check_request_exclusive,
2626
_params,
27+
_validate_max_pages,
2728
)
2829

2930

@@ -212,14 +213,19 @@ def list_all_rfqs(
212213
subaccount: int | None = None,
213214
status: str | None = None,
214215
creator_user_id: str | None = None,
216+
max_pages: int | None = None,
215217
) -> Iterator[RFQ]:
216218
self._require_auth()
219+
_validate_max_pages(max_pages)
217220
params = _list_rfqs_params(
218221
cursor=None, limit=limit, event_ticker=event_ticker,
219222
market_ticker=market_ticker, subaccount=subaccount,
220223
status=status, creator_user_id=creator_user_id,
221224
)
222-
yield from self._list_all("/communications/rfqs", RFQ, "rfqs", params=params)
225+
yield from self._list_all(
226+
"/communications/rfqs", RFQ, "rfqs",
227+
params=params, max_pages=max_pages,
228+
)
223229

224230
def get_rfq(self, rfq_id: str) -> GetRFQResponse:
225231
self._require_auth()
@@ -303,9 +309,11 @@ def list_all_quotes(
303309
rfq_creator_user_id: str | None = None,
304310
rfq_creator_subtrader_id: str | None = None,
305311
rfq_id: str | None = None,
312+
max_pages: int | None = None,
306313
) -> Iterator[Quote]:
307314
self._require_auth()
308315
_require_quote_filter(quote_creator_user_id, rfq_creator_user_id)
316+
_validate_max_pages(max_pages)
309317
params = _list_quotes_params(
310318
cursor=None, limit=limit, event_ticker=event_ticker,
311319
market_ticker=market_ticker, status=status,
@@ -315,7 +323,8 @@ def list_all_quotes(
315323
rfq_id=rfq_id,
316324
)
317325
return self._list_all(
318-
"/communications/quotes", Quote, "quotes", params=params,
326+
"/communications/quotes", Quote, "quotes",
327+
params=params, max_pages=max_pages,
319328
)
320329

321330
def get_quote(self, quote_id: str) -> GetQuoteResponse:
@@ -418,15 +427,18 @@ async def list_all_rfqs(
418427
subaccount: int | None = None,
419428
status: str | None = None,
420429
creator_user_id: str | None = None,
430+
max_pages: int | None = None,
421431
) -> AsyncIterator[RFQ]:
422432
self._require_auth()
433+
_validate_max_pages(max_pages)
423434
params = _list_rfqs_params(
424435
cursor=None, limit=limit, event_ticker=event_ticker,
425436
market_ticker=market_ticker, subaccount=subaccount,
426437
status=status, creator_user_id=creator_user_id,
427438
)
428439
async for item in self._list_all(
429-
"/communications/rfqs", RFQ, "rfqs", params=params,
440+
"/communications/rfqs", RFQ, "rfqs",
441+
params=params, max_pages=max_pages,
430442
):
431443
yield item
432444

@@ -514,9 +526,11 @@ def list_all_quotes(
514526
rfq_creator_user_id: str | None = None,
515527
rfq_creator_subtrader_id: str | None = None,
516528
rfq_id: str | None = None,
529+
max_pages: int | None = None,
517530
) -> AsyncIterator[Quote]:
518531
self._require_auth()
519532
_require_quote_filter(quote_creator_user_id, rfq_creator_user_id)
533+
_validate_max_pages(max_pages)
520534
params = _list_quotes_params(
521535
cursor=None, limit=limit, event_ticker=event_ticker,
522536
market_ticker=market_ticker, status=status,
@@ -526,7 +540,8 @@ def list_all_quotes(
526540
rfq_id=rfq_id,
527541
)
528542
return self._list_all(
529-
"/communications/quotes", Quote, "quotes", params=params,
543+
"/communications/quotes", Quote, "quotes",
544+
params=params, max_pages=max_pages,
530545
)
531546

532547
async def get_quote(self, quote_id: str) -> GetQuoteResponse:

kalshi/resources/events.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77

88
from kalshi.models.common import Page
99
from kalshi.models.events import Event, EventMetadata, EventStatusLiteral
10-
from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params
10+
from kalshi.resources._base import (
11+
AsyncResource,
12+
SyncResource,
13+
_bool_param,
14+
_params,
15+
_validate_max_pages,
16+
)
1117

1218
# Shared param builders (issue #46).
1319

@@ -86,15 +92,19 @@ def list_all(
8692
min_close_ts: int | None = None,
8793
min_updated_ts: int | None = None,
8894
limit: int | None = None,
95+
max_pages: int | None = None,
8996
) -> Iterator[Event]:
97+
_validate_max_pages(max_pages)
9098
params = _list_events_params(
9199
status=status, series_ticker=series_ticker,
92100
with_nested_markets=with_nested_markets,
93101
with_milestones=with_milestones,
94102
min_close_ts=min_close_ts, min_updated_ts=min_updated_ts,
95103
limit=limit, cursor=None,
96104
)
97-
return self._list_all("/events", Event, "events", params=params)
105+
return self._list_all(
106+
"/events", Event, "events", params=params, max_pages=max_pages,
107+
)
98108

99109
def list_multivariate(
100110
self,
@@ -120,14 +130,19 @@ def list_all_multivariate(
120130
collection_ticker: str | None = None,
121131
with_nested_markets: bool | None = None,
122132
limit: int | None = None,
133+
max_pages: int | None = None,
123134
) -> Iterator[Event]:
135+
_validate_max_pages(max_pages)
124136
params = _list_multivariate_events_params(
125137
series_ticker=series_ticker,
126138
collection_ticker=collection_ticker,
127139
with_nested_markets=with_nested_markets,
128140
limit=limit, cursor=None,
129141
)
130-
return self._list_all("/events/multivariate", Event, "events", params=params)
142+
return self._list_all(
143+
"/events/multivariate", Event, "events",
144+
params=params, max_pages=max_pages,
145+
)
131146

132147
def get(
133148
self,
@@ -180,15 +195,19 @@ def list_all(
180195
min_close_ts: int | None = None,
181196
min_updated_ts: int | None = None,
182197
limit: int | None = None,
198+
max_pages: int | None = None,
183199
) -> AsyncIterator[Event]:
200+
_validate_max_pages(max_pages)
184201
params = _list_events_params(
185202
status=status, series_ticker=series_ticker,
186203
with_nested_markets=with_nested_markets,
187204
with_milestones=with_milestones,
188205
min_close_ts=min_close_ts, min_updated_ts=min_updated_ts,
189206
limit=limit, cursor=None,
190207
)
191-
return self._list_all("/events", Event, "events", params=params)
208+
return self._list_all(
209+
"/events", Event, "events", params=params, max_pages=max_pages,
210+
)
192211

193212
async def list_multivariate(
194213
self,
@@ -214,14 +233,19 @@ def list_all_multivariate(
214233
collection_ticker: str | None = None,
215234
with_nested_markets: bool | None = None,
216235
limit: int | None = None,
236+
max_pages: int | None = None,
217237
) -> AsyncIterator[Event]:
238+
_validate_max_pages(max_pages)
218239
params = _list_multivariate_events_params(
219240
series_ticker=series_ticker,
220241
collection_ticker=collection_ticker,
221242
with_nested_markets=with_nested_markets,
222243
limit=limit, cursor=None,
223244
)
224-
return self._list_all("/events/multivariate", Event, "events", params=params)
245+
return self._list_all(
246+
"/events/multivariate", Event, "events",
247+
params=params, max_pages=max_pages,
248+
)
225249

226250
async def get(
227251
self,

kalshi/resources/fcm.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@
1818
from kalshi.models.common import Page
1919
from kalshi.models.orders import Order, OrderStatusLiteral
2020
from kalshi.models.portfolio import PositionsResponse, SettlementStatusLiteral
21-
from kalshi.resources._base import AsyncResource, SyncResource, _params
21+
from kalshi.resources._base import (
22+
AsyncResource,
23+
SyncResource,
24+
_params,
25+
_validate_max_pages,
26+
)
2227

2328
# Shared param builders (issue #46).
2429

@@ -100,14 +105,19 @@ def orders_all(
100105
min_ts: int | None = None,
101106
max_ts: int | None = None,
102107
limit: int | None = None,
108+
max_pages: int | None = None,
103109
) -> Iterator[Order]:
104110
self._require_auth()
111+
_validate_max_pages(max_pages)
105112
params = _fcm_orders_params(
106113
subtrader_id=subtrader_id, ticker=ticker,
107114
event_ticker=event_ticker, status=status,
108115
min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None,
109116
)
110-
return self._list_all("/fcm/orders", Order, "orders", params=params)
117+
return self._list_all(
118+
"/fcm/orders", Order, "orders",
119+
params=params, max_pages=max_pages,
120+
)
111121

112122
def positions(
113123
self,
@@ -164,15 +174,20 @@ def orders_all(
164174
min_ts: int | None = None,
165175
max_ts: int | None = None,
166176
limit: int | None = None,
177+
max_pages: int | None = None,
167178
) -> AsyncIterator[Order]:
168179
"""Returns an async iterator — use ``async for``."""
169180
self._require_auth()
181+
_validate_max_pages(max_pages)
170182
params = _fcm_orders_params(
171183
subtrader_id=subtrader_id, ticker=ticker,
172184
event_ticker=event_ticker, status=status,
173185
min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None,
174186
)
175-
return self._list_all("/fcm/orders", Order, "orders", params=params)
187+
return self._list_all(
188+
"/fcm/orders", Order, "orders",
189+
params=params, max_pages=max_pages,
190+
)
176191

177192
async def positions(
178193
self,

0 commit comments

Comments
 (0)