diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d88a8f..30efe4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to kalshi-sdk will be documented in this file. ## [Unreleased] +### Added + +- **`max_pages: int | None` kwarg on every public `*_all()` method** — sync and + async (19 + 19 method signatures). Bounded iteration without manual pagination. + `None` (default) iterates until the server returns no cursor; the existing + cursor-repeat guard remains the safety net against infinite loops (#98). +- **`RateLimit` model** exposed via `kalshi.RateLimit` — represents the per- + direction token-bucket structure on `AccountApiLimits.read` / `.write`. + +### Changed + +- **`*_all()` methods are now unbounded by default.** Previously, internal + `_list_all` had an invisible 1000-page safety cap that silently truncated + callers iterating beyond ~100k items. The cap is gone; the cursor-repeat + guard (`KalshiError` on a repeating cursor) provides the runaway protection + it always did. Callers who want a cap pass `max_pages=N` explicitly. No + user-visible change for anyone iterating <1000 pages (#98). + ### Fixed - **`/account/limits` response now parses against the live server.** The diff --git a/kalshi/resources/_base.py b/kalshi/resources/_base.py index 0e326d1..8a780c4 100644 --- a/kalshi/resources/_base.py +++ b/kalshi/resources/_base.py @@ -44,6 +44,20 @@ def _check_request_exclusive(request: Any, **kwargs: Any) -> None: ) +def _validate_max_pages(max_pages: int | None) -> None: + """Reject ``max_pages <= 0`` at the public ``*_all()`` boundary. + + ``None`` (unbounded — iterates until the server returns no cursor) and + positive integers are valid. Zero or negative would silently produce an + empty iterator deep inside ``_list_all``; surfacing the misuse here is + friendlier. + """ + if max_pages is not None and max_pages <= 0: + raise ValueError( + f"max_pages must be positive or None, got {max_pages}" + ) + + def _join_tickers(value: list[str] | tuple[str, ...] | str | None) -> str | None: """Serialize the `tickers` query param (spec: comma-joined string, not explode:true). @@ -151,7 +165,7 @@ def _list_all( items_key: str, *, params: dict[str, Any] | None = None, - max_pages: int = 1000, + max_pages: int | None = None, cursor_key: str = "cursor", ) -> Iterator[T]: """Auto-paginate through all pages, yielding items. @@ -160,18 +174,24 @@ def _list_all( convention). ``cursor_key`` only affects how the response envelope is parsed. + ``max_pages`` is an optional hard cap on pages fetched. ``None`` + (default) iterates until the server returns an empty cursor; the + cursor-repeat guard below provides the safety net against + infinite loops. + Raises ``KalshiError`` if a cursor value repeats, which indicates - a server-side pagination bug that would otherwise cause the safety - cap to silently issue ``max_pages`` duplicate requests. + a server-side pagination bug. """ current_params = dict(params) if params else {} seen_cursors: set[str] = set() - for _ in range(max_pages): + pages_fetched = 0 + while max_pages is None or pages_fetched < max_pages: page = self._list( path, model_cls, items_key, params=current_params, cursor_key=cursor_key, ) yield from page.items + pages_fetched += 1 if not page.cursor: break if page.cursor in seen_cursors: @@ -264,21 +284,24 @@ async def _list_all( items_key: str, *, params: dict[str, Any] | None = None, - max_pages: int = 1000, + max_pages: int | None = None, cursor_key: str = "cursor", ) -> AsyncIterator[T]: - """Async counterpart of ``SyncResource._list_all``. Raises - ``KalshiError`` on repeated cursor; see sync docstring. + """Async counterpart of :meth:`SyncResource._list_all`. + + Raises ``KalshiError`` on repeated cursor; see sync docstring. """ current_params = dict(params) if params else {} seen_cursors: set[str] = set() - for _ in range(max_pages): + pages_fetched = 0 + while max_pages is None or pages_fetched < max_pages: page = await self._list( path, model_cls, items_key, params=current_params, cursor_key=cursor_key, ) for item in page.items: yield item + pages_fetched += 1 if not page.cursor: break if page.cursor in seen_cursors: diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index 14807f6..bf5296f 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -24,6 +24,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_max_pages, ) @@ -212,14 +213,19 @@ def list_all_rfqs( subaccount: int | None = None, status: str | None = None, creator_user_id: str | None = None, + max_pages: int | None = None, ) -> Iterator[RFQ]: self._require_auth() + _validate_max_pages(max_pages) params = _list_rfqs_params( cursor=None, limit=limit, event_ticker=event_ticker, market_ticker=market_ticker, subaccount=subaccount, status=status, creator_user_id=creator_user_id, ) - yield from self._list_all("/communications/rfqs", RFQ, "rfqs", params=params) + return self._list_all( + "/communications/rfqs", RFQ, "rfqs", + params=params, max_pages=max_pages, + ) def get_rfq(self, rfq_id: str) -> GetRFQResponse: self._require_auth() @@ -303,9 +309,11 @@ def list_all_quotes( rfq_creator_user_id: str | None = None, rfq_creator_subtrader_id: str | None = None, rfq_id: str | None = None, + max_pages: int | None = None, ) -> Iterator[Quote]: self._require_auth() _require_quote_filter(quote_creator_user_id, rfq_creator_user_id) + _validate_max_pages(max_pages) params = _list_quotes_params( cursor=None, limit=limit, event_ticker=event_ticker, market_ticker=market_ticker, status=status, @@ -315,7 +323,8 @@ def list_all_quotes( rfq_id=rfq_id, ) return self._list_all( - "/communications/quotes", Quote, "quotes", params=params, + "/communications/quotes", Quote, "quotes", + params=params, max_pages=max_pages, ) def get_quote(self, quote_id: str) -> GetQuoteResponse: @@ -409,7 +418,7 @@ async def list_rfqs( ) return await self._list("/communications/rfqs", RFQ, "rfqs", params=params) - async def list_all_rfqs( + def list_all_rfqs( self, *, limit: int | None = None, @@ -418,17 +427,20 @@ async def list_all_rfqs( subaccount: int | None = None, status: str | None = None, creator_user_id: str | None = None, + max_pages: int | None = None, ) -> AsyncIterator[RFQ]: + # Plain `def` so _require_auth + _validate_max_pages run at call time. self._require_auth() + _validate_max_pages(max_pages) params = _list_rfqs_params( cursor=None, limit=limit, event_ticker=event_ticker, market_ticker=market_ticker, subaccount=subaccount, status=status, creator_user_id=creator_user_id, ) - async for item in self._list_all( - "/communications/rfqs", RFQ, "rfqs", params=params, - ): - yield item + return self._list_all( + "/communications/rfqs", RFQ, "rfqs", + params=params, max_pages=max_pages, + ) async def get_rfq(self, rfq_id: str) -> GetRFQResponse: self._require_auth() @@ -514,9 +526,11 @@ def list_all_quotes( rfq_creator_user_id: str | None = None, rfq_creator_subtrader_id: str | None = None, rfq_id: str | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Quote]: self._require_auth() _require_quote_filter(quote_creator_user_id, rfq_creator_user_id) + _validate_max_pages(max_pages) params = _list_quotes_params( cursor=None, limit=limit, event_ticker=event_ticker, market_ticker=market_ticker, status=status, @@ -526,7 +540,8 @@ def list_all_quotes( rfq_id=rfq_id, ) return self._list_all( - "/communications/quotes", Quote, "quotes", params=params, + "/communications/quotes", Quote, "quotes", + params=params, max_pages=max_pages, ) async def get_quote(self, quote_id: str) -> GetQuoteResponse: diff --git a/kalshi/resources/events.py b/kalshi/resources/events.py index d780272..fc85f86 100644 --- a/kalshi/resources/events.py +++ b/kalshi/resources/events.py @@ -7,7 +7,13 @@ from kalshi.models.common import Page from kalshi.models.events import Event, EventMetadata, EventStatusLiteral -from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _bool_param, + _params, + _validate_max_pages, +) # Shared param builders (issue #46). @@ -86,7 +92,9 @@ def list_all( min_close_ts: int | None = None, min_updated_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Event]: + _validate_max_pages(max_pages) params = _list_events_params( status=status, series_ticker=series_ticker, with_nested_markets=with_nested_markets, @@ -94,7 +102,9 @@ def list_all( min_close_ts=min_close_ts, min_updated_ts=min_updated_ts, limit=limit, cursor=None, ) - return self._list_all("/events", Event, "events", params=params) + return self._list_all( + "/events", Event, "events", params=params, max_pages=max_pages, + ) def list_multivariate( self, @@ -120,14 +130,19 @@ def list_all_multivariate( collection_ticker: str | None = None, with_nested_markets: bool | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Event]: + _validate_max_pages(max_pages) params = _list_multivariate_events_params( series_ticker=series_ticker, collection_ticker=collection_ticker, with_nested_markets=with_nested_markets, limit=limit, cursor=None, ) - return self._list_all("/events/multivariate", Event, "events", params=params) + return self._list_all( + "/events/multivariate", Event, "events", + params=params, max_pages=max_pages, + ) def get( self, @@ -180,7 +195,9 @@ def list_all( min_close_ts: int | None = None, min_updated_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Event]: + _validate_max_pages(max_pages) params = _list_events_params( status=status, series_ticker=series_ticker, with_nested_markets=with_nested_markets, @@ -188,7 +205,9 @@ def list_all( min_close_ts=min_close_ts, min_updated_ts=min_updated_ts, limit=limit, cursor=None, ) - return self._list_all("/events", Event, "events", params=params) + return self._list_all( + "/events", Event, "events", params=params, max_pages=max_pages, + ) async def list_multivariate( self, @@ -214,14 +233,19 @@ def list_all_multivariate( collection_ticker: str | None = None, with_nested_markets: bool | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Event]: + _validate_max_pages(max_pages) params = _list_multivariate_events_params( series_ticker=series_ticker, collection_ticker=collection_ticker, with_nested_markets=with_nested_markets, limit=limit, cursor=None, ) - return self._list_all("/events/multivariate", Event, "events", params=params) + return self._list_all( + "/events/multivariate", Event, "events", + params=params, max_pages=max_pages, + ) async def get( self, diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index 212a689..121a229 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -18,7 +18,12 @@ from kalshi.models.common import Page from kalshi.models.orders import Order, OrderStatusLiteral from kalshi.models.portfolio import PositionsResponse, SettlementStatusLiteral -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) # Shared param builders (issue #46). @@ -100,14 +105,19 @@ def orders_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _fcm_orders_params( subtrader_id=subtrader_id, ticker=ticker, event_ticker=event_ticker, status=status, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, ) - return self._list_all("/fcm/orders", Order, "orders", params=params) + return self._list_all( + "/fcm/orders", Order, "orders", + params=params, max_pages=max_pages, + ) def positions( self, @@ -164,15 +174,20 @@ def orders_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Order]: """Returns an async iterator — use ``async for``.""" self._require_auth() + _validate_max_pages(max_pages) params = _fcm_orders_params( subtrader_id=subtrader_id, ticker=ticker, event_ticker=event_ticker, status=status, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, ) - return self._list_all("/fcm/orders", Order, "orders", params=params) + return self._list_all( + "/fcm/orders", Order, "orders", + params=params, max_pages=max_pages, + ) async def positions( self, diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 549e216..3de7f39 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -14,7 +14,13 @@ ) from kalshi.models.markets import Candlestick, Market from kalshi.models.orders import Fill, Order -from kalshi.resources._base import AsyncResource, SyncResource, _join_tickers, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _join_tickers, + _params, + _validate_max_pages, +) # Shared param builders (issue #46). @@ -103,13 +109,18 @@ def markets_all( event_ticker: str | None = None, series_ticker: str | None = None, mve_filter: MveHistoricalFilterLiteral | None = None, + max_pages: int | None = None, ) -> Iterator[Market]: + _validate_max_pages(max_pages) params = _historical_markets_params( limit=limit, cursor=None, tickers=tickers, event_ticker=event_ticker, series_ticker=series_ticker, mve_filter=mve_filter, ) - return self._list_all("/historical/markets", Market, "markets", params=params) + return self._list_all( + "/historical/markets", Market, "markets", + params=params, max_pages=max_pages, + ) def market(self, ticker: str) -> Market: data = self._get(f"/historical/markets/{ticker}") @@ -155,12 +166,17 @@ def fills_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/fills", Fill, "fills", params=params) + return self._list_all( + "/historical/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) def orders( self, @@ -182,12 +198,17 @@ def orders_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/orders", Order, "orders", params=params) + return self._list_all( + "/historical/orders", Order, "orders", + params=params, max_pages=max_pages, + ) def trades( self, @@ -211,12 +232,17 @@ def trades_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Trade]: + _validate_max_pages(max_pages) params = _historical_trades_params( limit=limit, cursor=None, ticker=ticker, min_ts=min_ts, max_ts=max_ts, ) - return self._list_all("/historical/trades", Trade, "trades", params=params) + return self._list_all( + "/historical/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) class AsyncHistoricalResource(AsyncResource): """Async historical data API.""" @@ -250,13 +276,18 @@ def markets_all( event_ticker: str | None = None, series_ticker: str | None = None, mve_filter: MveHistoricalFilterLiteral | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Market]: + _validate_max_pages(max_pages) params = _historical_markets_params( limit=limit, cursor=None, tickers=tickers, event_ticker=event_ticker, series_ticker=series_ticker, mve_filter=mve_filter, ) - return self._list_all("/historical/markets", Market, "markets", params=params) + return self._list_all( + "/historical/markets", Market, "markets", + params=params, max_pages=max_pages, + ) async def market(self, ticker: str) -> Market: data = await self._get(f"/historical/markets/{ticker}") @@ -302,12 +333,17 @@ def fills_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/fills", Fill, "fills", params=params) + return self._list_all( + "/historical/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) async def orders( self, @@ -329,12 +365,17 @@ def orders_all( limit: int | None = None, ticker: str | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _historical_fills_or_orders_params( limit=limit, cursor=None, ticker=ticker, max_ts=max_ts, ) - return self._list_all("/historical/orders", Order, "orders", params=params) + return self._list_all( + "/historical/orders", Order, "orders", + params=params, max_pages=max_pages, + ) async def trades( self, @@ -358,9 +399,14 @@ def trades_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Trade]: + _validate_max_pages(max_pages) params = _historical_trades_params( limit=limit, cursor=None, ticker=ticker, min_ts=min_ts, max_ts=max_ts, ) - return self._list_all("/historical/trades", Trade, "trades", params=params) + return self._list_all( + "/historical/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) diff --git a/kalshi/resources/incentive_programs.py b/kalshi/resources/incentive_programs.py index 701abd8..ae8280e 100644 --- a/kalshi/resources/incentive_programs.py +++ b/kalshi/resources/incentive_programs.py @@ -15,7 +15,12 @@ IncentiveProgramStatusLiteral, IncentiveProgramTypeLiteral, ) -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) class IncentiveProgramsResource(SyncResource): @@ -53,13 +58,16 @@ def list_all( status: IncentiveProgramStatusLiteral | None = None, incentive_type: IncentiveProgramTypeLiteral | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[IncentiveProgram]: + _validate_max_pages(max_pages) params = _params(status=status, type=incentive_type, limit=limit) - yield from self._list_all( + return self._list_all( "/incentive_programs", IncentiveProgram, "incentive_programs", params=params, + max_pages=max_pages, cursor_key="next_cursor", ) @@ -95,13 +103,16 @@ def list_all( status: IncentiveProgramStatusLiteral | None = None, incentive_type: IncentiveProgramTypeLiteral | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[IncentiveProgram]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _params(status=status, type=incentive_type, limit=limit) return self._list_all( "/incentive_programs", IncentiveProgram, "incentive_programs", params=params, + max_pages=max_pages, cursor_key="next_cursor", ) diff --git a/kalshi/resources/markets.py b/kalshi/resources/markets.py index 6a61d0a..c09454d 100644 --- a/kalshi/resources/markets.py +++ b/kalshi/resources/markets.py @@ -24,6 +24,7 @@ _bool_param, _join_tickers, _params, + _validate_max_pages, ) _MAX_BULK = 100 @@ -264,7 +265,9 @@ def list_all( min_settled_ts: int | None = None, max_settled_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Market]: + _validate_max_pages(max_pages) params = _list_markets_params( status=status, series_ticker=series_ticker, event_ticker=event_ticker, tickers=tickers, @@ -275,7 +278,10 @@ def list_all( min_settled_ts=min_settled_ts, max_settled_ts=max_settled_ts, limit=limit, cursor=None, ) - return self._list_all("/markets", Market, "markets", params=params) + return self._list_all( + "/markets", Market, "markets", + params=params, max_pages=max_pages, + ) def get(self, ticker: str) -> Market: data = self._get(f"/markets/{ticker}") @@ -332,12 +338,17 @@ def list_trades_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[Trade]: + _validate_max_pages(max_pages) params = _list_trades_params( ticker=ticker, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, ) - return self._list_all("/markets/trades", Trade, "trades", params=params) + return self._list_all( + "/markets/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) def bulk_candlesticks( self, @@ -429,8 +440,10 @@ def list_all( min_settled_ts: int | None = None, max_settled_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Market]: """Non-async method that returns an async iterator for direct use with `async for`.""" + _validate_max_pages(max_pages) params = _list_markets_params( status=status, series_ticker=series_ticker, event_ticker=event_ticker, tickers=tickers, @@ -441,7 +454,10 @@ def list_all( min_settled_ts=min_settled_ts, max_settled_ts=max_settled_ts, limit=limit, cursor=None, ) - return self._list_all("/markets", Market, "markets", params=params) + return self._list_all( + "/markets", Market, "markets", + params=params, max_pages=max_pages, + ) async def get(self, ticker: str) -> Market: data = await self._get(f"/markets/{ticker}") @@ -498,13 +514,18 @@ def list_trades_all( min_ts: int | None = None, max_ts: int | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Trade]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _list_trades_params( ticker=ticker, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, ) - return self._list_all("/markets/trades", Trade, "trades", params=params) + return self._list_all( + "/markets/trades", Trade, "trades", + params=params, max_pages=max_pages, + ) async def bulk_candlesticks( self, diff --git a/kalshi/resources/milestones.py b/kalshi/resources/milestones.py index 94c4c07..f63d010 100644 --- a/kalshi/resources/milestones.py +++ b/kalshi/resources/milestones.py @@ -8,7 +8,12 @@ from kalshi.models.common import Page from kalshi.models.milestones import GetMilestoneResponse, Milestone -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) def _iso(dt: datetime | str | None) -> str | None: @@ -104,7 +109,9 @@ def list_all( milestone_type: str | None = None, related_event_ticker: str | None = None, min_updated_ts: int | None = None, + max_pages: int | None = None, ) -> Iterator[Milestone]: + _validate_max_pages(max_pages) params = _list_milestones_params( limit=limit, minimum_start_date=minimum_start_date, category=category, competition=competition, @@ -112,8 +119,9 @@ def list_all( related_event_ticker=related_event_ticker, cursor=None, min_updated_ts=min_updated_ts, ) - yield from self._list_all( - "/milestones", Milestone, "milestones", params=params, + return self._list_all( + "/milestones", Milestone, "milestones", + params=params, max_pages=max_pages, ) def get(self, milestone_id: str) -> Milestone: @@ -159,8 +167,10 @@ def list_all( milestone_type: str | None = None, related_event_ticker: str | None = None, min_updated_ts: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Milestone]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _list_milestones_params( limit=limit, minimum_start_date=minimum_start_date, category=category, competition=competition, @@ -169,7 +179,8 @@ def list_all( cursor=None, min_updated_ts=min_updated_ts, ) return self._list_all( - "/milestones", Milestone, "milestones", params=params, + "/milestones", Milestone, "milestones", + params=params, max_pages=max_pages, ) async def get(self, milestone_id: str) -> Milestone: diff --git a/kalshi/resources/multivariate.py b/kalshi/resources/multivariate.py index 7591c16..90527cd 100644 --- a/kalshi/resources/multivariate.py +++ b/kalshi/resources/multivariate.py @@ -22,6 +22,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_max_pages, ) # Shared param + body builders (issue #46). @@ -131,7 +132,9 @@ def list_all( associated_event_ticker: str | None = None, series_ticker: str | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[MultivariateEventCollection]: + _validate_max_pages(max_pages) params = _list_collections_params( status=status, associated_event_ticker=associated_event_ticker, @@ -143,6 +146,7 @@ def list_all( MultivariateEventCollection, "multivariate_contracts", params=params, + max_pages=max_pages, ) def get(self, collection_ticker: str) -> MultivariateEventCollection: @@ -266,7 +270,9 @@ def list_all( associated_event_ticker: str | None = None, series_ticker: str | None = None, limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[MultivariateEventCollection]: + _validate_max_pages(max_pages) params = _list_collections_params( status=status, associated_event_ticker=associated_event_ticker, @@ -278,6 +284,7 @@ def list_all( MultivariateEventCollection, "multivariate_contracts", params=params, + max_pages=max_pages, ) async def get(self, collection_ticker: str) -> MultivariateEventCollection: diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 4dde72d..fe97ae3 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -32,6 +32,7 @@ _check_request_exclusive, _join_tickers, _params, + _validate_max_pages, ) from kalshi.types import to_decimal @@ -396,14 +397,19 @@ def list_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> Iterator[Order]: self._require_auth() + _validate_max_pages(max_pages) params = _list_orders_params( ticker=ticker, event_ticker=event_ticker, status=status, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/orders", Order, "orders", params=params) + return self._list_all( + "/portfolio/orders", Order, "orders", + params=params, max_pages=max_pages, + ) @overload def batch_create( @@ -492,14 +498,19 @@ def fills_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> Iterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _fills_params( ticker=ticker, order_id=order_id, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/fills", Fill, "fills", params=params) + return self._list_all( + "/portfolio/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) @overload def amend( @@ -721,15 +732,20 @@ def list_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Order]: """Non-async method that returns an async iterator for direct use with `async for`.""" self._require_auth() + _validate_max_pages(max_pages) params = _list_orders_params( ticker=ticker, event_ticker=event_ticker, status=status, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/orders", Order, "orders", params=params) + return self._list_all( + "/portfolio/orders", Order, "orders", + params=params, max_pages=max_pages, + ) @overload async def batch_create( @@ -818,14 +834,19 @@ def fills_all( max_ts: int | None = None, limit: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Fill]: self._require_auth() + _validate_max_pages(max_pages) params = _fills_params( ticker=ticker, order_id=order_id, min_ts=min_ts, max_ts=max_ts, limit=limit, cursor=None, subaccount=subaccount, ) - return self._list_all("/portfolio/fills", Fill, "fills", params=params) + return self._list_all( + "/portfolio/fills", Fill, "fills", + params=params, max_pages=max_pages, + ) @overload async def amend( diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 638dc89..8166e45 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -12,7 +12,12 @@ Settlement, TotalRestingOrderValue, ) -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) # Shared param builders (issue #46). @@ -112,14 +117,19 @@ def settlements_all( min_ts: int | None = None, max_ts: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> Iterator[Settlement]: self._require_auth() + _validate_max_pages(max_pages) params = _settlements_params( limit=limit, cursor=None, ticker=ticker, event_ticker=event_ticker, min_ts=min_ts, max_ts=max_ts, subaccount=subaccount, ) - return self._list_all("/portfolio/settlements", Settlement, "settlements", params=params) + return self._list_all( + "/portfolio/settlements", Settlement, "settlements", + params=params, max_pages=max_pages, + ) def total_resting_order_value(self) -> TotalRestingOrderValue: """Total value of resting orders in cents. FCM-members only. @@ -189,15 +199,18 @@ def settlements_all( min_ts: int | None = None, max_ts: int | None = None, subaccount: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[Settlement]: self._require_auth() + _validate_max_pages(max_pages) params = _settlements_params( limit=limit, cursor=None, ticker=ticker, event_ticker=event_ticker, min_ts=min_ts, max_ts=max_ts, subaccount=subaccount, ) return self._list_all( - "/portfolio/settlements", Settlement, "settlements", params=params + "/portfolio/settlements", Settlement, "settlements", + params=params, max_pages=max_pages, ) async def total_resting_order_value(self) -> TotalRestingOrderValue: diff --git a/kalshi/resources/structured_targets.py b/kalshi/resources/structured_targets.py index 5c928b7..a0ce2af 100644 --- a/kalshi/resources/structured_targets.py +++ b/kalshi/resources/structured_targets.py @@ -10,7 +10,12 @@ GetStructuredTargetResponse, StructuredTarget, ) -from kalshi.resources._base import AsyncResource, SyncResource, _params +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_max_pages, +) class StructuredTargetsResource(SyncResource): @@ -52,18 +57,21 @@ def list_all( target_type: str | None = None, competition: str | None = None, page_size: int | None = None, + max_pages: int | None = None, ) -> Iterator[StructuredTarget]: + _validate_max_pages(max_pages) params = _params( ids=ids, type=target_type, competition=competition, page_size=page_size, ) - yield from self._list_all( + return self._list_all( "/structured_targets", StructuredTarget, "structured_targets", params=params, + max_pages=max_pages, ) def get(self, structured_target_id: str) -> StructuredTarget | None: @@ -104,8 +112,10 @@ def list_all( target_type: str | None = None, competition: str | None = None, page_size: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[StructuredTarget]: """Returns an async iterator — use ``async for``.""" + _validate_max_pages(max_pages) params = _params( ids=ids, type=target_type, @@ -117,6 +127,7 @@ def list_all( StructuredTarget, "structured_targets", params=params, + max_pages=max_pages, ) async def get(self, structured_target_id: str) -> StructuredTarget | None: diff --git a/kalshi/resources/subaccounts.py b/kalshi/resources/subaccounts.py index c650cf6..4080daa 100644 --- a/kalshi/resources/subaccounts.py +++ b/kalshi/resources/subaccounts.py @@ -20,6 +20,7 @@ SyncResource, _check_request_exclusive, _params, + _validate_max_pages, ) # Shared body builders (issue #46). @@ -151,15 +152,20 @@ def list_transfers( ) def list_all_transfers( - self, *, limit: int | None = None, + self, + *, + limit: int | None = None, + max_pages: int | None = None, ) -> Iterator[SubaccountTransfer]: self._require_auth() + _validate_max_pages(max_pages) params = _params(limit=limit) - yield from self._list_all( + return self._list_all( "/portfolio/subaccounts/transfers", SubaccountTransfer, "transfers", params=params, + max_pages=max_pages, ) @overload @@ -248,18 +254,24 @@ async def list_transfers( params=params, ) - async def list_all_transfers( - self, *, limit: int | None = None, + def list_all_transfers( + self, + *, + limit: int | None = None, + max_pages: int | None = None, ) -> AsyncIterator[SubaccountTransfer]: + # Plain `def` (not `async def`) so _require_auth and _validate_max_pages + # run at call time, not when the returned AsyncIterator is awaited. self._require_auth() + _validate_max_pages(max_pages) params = _params(limit=limit) - async for item in self._list_all( + return self._list_all( "/portfolio/subaccounts/transfers", SubaccountTransfer, "transfers", params=params, - ): - yield item + max_pages=max_pages, + ) @overload async def update_netting( diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 7fb1bed..95bebee 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -24,6 +24,7 @@ "paginator_handled", "wire_normalization", "kwarg_rename", + "client_only", ] @@ -65,6 +66,9 @@ class Exclusion: shadowing a Python built-in (e.g., spec's ``type`` becomes ``milestone_type`` / ``target_type`` / ``incentive_type``). The wire value is unchanged; only the Python signature differs. + - ``client_only``: SDK kwarg with no wire counterpart — purely a + client-side knob (e.g., ``max_pages`` cap on ``*_all`` paginators). + Legitimately present in the signature; not drift. """ reason: str @@ -1034,6 +1038,57 @@ class Exclusion: } +# --- max_pages: client-side cap on every public *_all() method (#98) --- +# Not a wire param — pure paginator safety knob. Every public *_all() +# accepts it; the kwarg legitimately exists in the signature. +_MAX_PAGES_FQNS: tuple[str, ...] = ( + "kalshi.resources.markets.MarketsResource.list_all", + "kalshi.resources.markets.MarketsResource.list_trades_all", + "kalshi.resources.milestones.MilestonesResource.list_all", + "kalshi.resources.events.EventsResource.list_all", + "kalshi.resources.events.EventsResource.list_all_multivariate", + "kalshi.resources.historical.HistoricalResource.markets_all", + "kalshi.resources.historical.HistoricalResource.fills_all", + "kalshi.resources.historical.HistoricalResource.orders_all", + "kalshi.resources.historical.HistoricalResource.trades_all", + "kalshi.resources.orders.OrdersResource.list_all", + "kalshi.resources.orders.OrdersResource.fills_all", + "kalshi.resources.communications.CommunicationsResource.list_all_rfqs", + "kalshi.resources.communications.CommunicationsResource.list_all_quotes", + "kalshi.resources.subaccounts.SubaccountsResource.list_all_transfers", + "kalshi.resources.portfolio.PortfolioResource.settlements_all", + "kalshi.resources.fcm.FcmResource.orders_all", + "kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all", + "kalshi.resources.structured_targets.StructuredTargetsResource.list_all", + "kalshi.resources.multivariate.MultivariateCollectionsResource.list_all", + # Async counterparts — same kwarg, same client-only semantics. + "kalshi.resources.markets.AsyncMarketsResource.list_all", + "kalshi.resources.markets.AsyncMarketsResource.list_trades_all", + "kalshi.resources.milestones.AsyncMilestonesResource.list_all", + "kalshi.resources.events.AsyncEventsResource.list_all", + "kalshi.resources.events.AsyncEventsResource.list_all_multivariate", + "kalshi.resources.historical.AsyncHistoricalResource.markets_all", + "kalshi.resources.historical.AsyncHistoricalResource.fills_all", + "kalshi.resources.historical.AsyncHistoricalResource.orders_all", + "kalshi.resources.historical.AsyncHistoricalResource.trades_all", + "kalshi.resources.orders.AsyncOrdersResource.list_all", + "kalshi.resources.orders.AsyncOrdersResource.fills_all", + "kalshi.resources.communications.AsyncCommunicationsResource.list_all_rfqs", + "kalshi.resources.communications.AsyncCommunicationsResource.list_all_quotes", + "kalshi.resources.subaccounts.AsyncSubaccountsResource.list_all_transfers", + "kalshi.resources.portfolio.AsyncPortfolioResource.settlements_all", + "kalshi.resources.fcm.AsyncFcmResource.orders_all", + "kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource.list_all", + "kalshi.resources.structured_targets.AsyncStructuredTargetsResource.list_all", + "kalshi.resources.multivariate.AsyncMultivariateCollectionsResource.list_all", +) +for _fqn in _MAX_PAGES_FQNS: + EXCLUSIONS[(_fqn, "max_pages")] = Exclusion( + reason="client-side max_pages cap on *_all() paginator; not a wire param (#98)", + kind="client_only", + ) + + def _resolve_ref( spec: dict[str, Any], ref: str, diff --git a/tests/test_base_helpers.py b/tests/test_base_helpers.py index 2497006..7dd46b4 100644 --- a/tests/test_base_helpers.py +++ b/tests/test_base_helpers.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import httpx import pytest import respx @@ -11,7 +13,12 @@ from kalshi.auth import KalshiAuth from kalshi.config import KalshiConfig from kalshi.errors import KalshiError -from kalshi.resources._base import AsyncResource, SyncResource, _join_tickers +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _join_tickers, + _validate_max_pages, +) class _Item(BaseModel): @@ -147,7 +154,7 @@ class TestSyncListAllCursorLoopDetection: def test_repeated_cursor_raises( self, test_auth: KalshiAuth, test_config: KalshiConfig ) -> None: - """Server that returns the same cursor twice must bail fast, not retry 1000x.""" + """Server that returns the same cursor twice must bail fast on the 2nd request.""" route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( return_value=httpx.Response( 200, json={"items": [{"id": "x"}], "cursor": "loop"} @@ -159,8 +166,7 @@ def test_repeated_cursor_raises( list(resource._list_all("/things", _Item, "items")) # First call (no cursor) fetches cursor="loop". Second call (cursor=loop) returns - # cursor="loop" again → loop detected before a third request. Total: 2 requests, - # not 1000. + # cursor="loop" again → loop detected before a third request. assert route.call_count == 2 @respx.mock @@ -217,3 +223,251 @@ async def test_repeated_cursor_raises( pass assert route.call_count == 2 + + +def _fresh_cursor_side_effect() -> Any: + """Return a respx side_effect that yields a fresh unique cursor per call. + + Each response has one item and a never-repeated cursor so cursor-loop + detection never trips; only ``max_pages`` can stop the iterator. + """ + counter = {"n": 0} + + def _make_response(request: httpx.Request) -> httpx.Response: + counter["n"] += 1 + n = counter["n"] + return httpx.Response( + 200, json={"items": [{"id": f"item-{n}"}], "cursor": f"cur-{n}"}, + ) + + return _make_response + + +class TestSyncListAllMaxPagesCap: + """Cover the bare numeric ``max_pages`` cap in ``_list_all`` (#98).""" + + @respx.mock + def test_cap_of_one_fetches_single_page( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = SyncResource(SyncTransport(test_auth, test_config)) + + collected = list( + resource._list_all("/things", _Item, "items", max_pages=1) + ) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["item-1"] + + @respx.mock + def test_cap_of_three_stops_at_three( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = SyncResource(SyncTransport(test_auth, test_config)) + + collected = list( + resource._list_all("/things", _Item, "items", max_pages=3) + ) + + assert route.call_count == 3 + assert [item.id for item in collected] == ["item-1", "item-2", "item-3"] + + @respx.mock + def test_empty_cursor_stops_after_one_request( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + """Regression guard (F-Q-17): empty-string cursor terminates _list_all.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + return_value=httpx.Response( + 200, json={"items": [{"id": "a"}], "cursor": ""} + ) + ) + resource = SyncResource(SyncTransport(test_auth, test_config)) + + collected = list(resource._list_all("/things", _Item, "items")) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["a"] + + +class TestAsyncListAllMaxPagesCap: + """Async sibling of TestSyncListAllMaxPagesCap (#98).""" + + @respx.mock + @pytest.mark.asyncio + async def test_cap_of_one_fetches_single_page( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + + collected: list[_Item] = [] + async for item in resource._list_all( + "/things", _Item, "items", max_pages=1 + ): + collected.append(item) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["item-1"] + + @respx.mock + @pytest.mark.asyncio + async def test_cap_of_three_stops_at_three( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + side_effect=_fresh_cursor_side_effect() + ) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + + collected: list[_Item] = [] + async for item in resource._list_all( + "/things", _Item, "items", max_pages=3 + ): + collected.append(item) + + assert route.call_count == 3 + assert [item.id for item in collected] == ["item-1", "item-2", "item-3"] + + @respx.mock + @pytest.mark.asyncio + async def test_empty_cursor_stops_after_one_request( + self, test_auth: KalshiAuth, test_config: KalshiConfig + ) -> None: + """Async regression guard (F-Q-17): empty-string cursor terminates.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/things").mock( + return_value=httpx.Response( + 200, json={"items": [{"id": "a"}], "cursor": ""} + ) + ) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + + collected: list[_Item] = [] + async for item in resource._list_all("/things", _Item, "items"): + collected.append(item) + + assert route.call_count == 1 + assert [item.id for item in collected] == ["a"] + + +class TestValidateMaxPages: + """``_validate_max_pages`` rejects non-positive values at the public boundary.""" + + def test_none_allowed(self) -> None: + _validate_max_pages(None) # no raise + + def test_positive_allowed(self) -> None: + _validate_max_pages(1) + _validate_max_pages(1000) + + def test_zero_rejected(self) -> None: + with pytest.raises(ValueError, match=r"max_pages must be positive"): + _validate_max_pages(0) + + def test_negative_rejected(self) -> None: + with pytest.raises(ValueError, match=r"max_pages must be positive"): + _validate_max_pages(-3) + + +class TestMaxPagesEagerValidation: + """Regression: ``_validate_max_pages`` must fire at call time, not on first iteration. + + Methods that were previously written with ``yield from``/``async for ... yield`` + silently became generators — their body didn't execute until the caller advanced + the iterator, so `max_pages=0` got deferred. All `*_all()` methods must now use + `return self._list_all(...)` so the validator runs eagerly. + """ + + def test_sync_milestones_list_all_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.milestones import MilestonesResource + resource = MilestonesResource(SyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all(limit=10, max_pages=0) + + def test_sync_subaccounts_list_all_transfers_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.subaccounts import SubaccountsResource + resource = SubaccountsResource(SyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all_transfers(max_pages=0) + + @pytest.mark.asyncio + async def test_async_subaccounts_list_all_transfers_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.subaccounts import AsyncSubaccountsResource + resource = AsyncSubaccountsResource(AsyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all_transfers(max_pages=0) + + @pytest.mark.asyncio + async def test_async_communications_list_all_rfqs_validates_eagerly( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + from kalshi.resources.communications import AsyncCommunicationsResource + resource = AsyncCommunicationsResource(AsyncTransport(test_auth, test_config)) + with pytest.raises(ValueError, match=r"max_pages must be positive"): + resource.list_all_rfqs(max_pages=0) + + +class TestMaxPagesNoneIsUnbounded: + """``max_pages=None`` must iterate until the server returns no cursor. + + The 1000-page default was removed: cursor-repeat guard is the real safety net. + """ + + @respx.mock + def test_sync_iterates_past_1000_pages( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + # 1010 just exceeds the old 1000 cap — proves the cap is gone without + # making the test 100x slower than a normal pagination test. + total = 1010 + call_counter = {"n": 0} + + def responder(request: httpx.Request) -> httpx.Response: + call_counter["n"] += 1 + n = call_counter["n"] + cursor = str(n) if n < total else "" + return httpx.Response( + 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, + ) + + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) + resource = SyncResource(SyncTransport(test_auth, test_config)) + items = list(resource._list_all("/things", _Item, "items")) + assert len(items) == total, f"Expected {total} items, got {len(items)} (cap leaked?)" + + @respx.mock + @pytest.mark.asyncio + async def test_async_iterates_past_1000_pages( + self, test_auth: KalshiAuth, test_config: KalshiConfig, + ) -> None: + # Async counterpart — same 1010-page proof, prevents the regression + # from silently re-appearing in only one of the two code paths. + total = 1010 + call_counter = {"n": 0} + + def responder(request: httpx.Request) -> httpx.Response: + call_counter["n"] += 1 + n = call_counter["n"] + cursor = str(n) if n < total else "" + return httpx.Response( + 200, json={"items": [{"id": f"i{n}"}], "cursor": cursor}, + ) + + respx.get("https://test.kalshi.com/trade-api/v2/things").mock(side_effect=responder) + resource = AsyncResource(AsyncTransport(test_auth, test_config)) + items = [item async for item in resource._list_all("/things", _Item, "items")] + assert len(items) == total, f"Expected {total} items, got {len(items)} (cap leaked?)" diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 450dd8c..8077af3 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1381,7 +1381,9 @@ def test_exclusion_map_is_current() -> None: # kwarg like 'milestone_type' for spec's 'type'), in which case the # kwarg legitimately exists in the signature. Distinguish by the # typed ``kind`` field (issue #51). - sig_mismatch_kinds = {"body_param", "wire_normalization", "kwarg_rename"} + sig_mismatch_kinds = { + "body_param", "wire_normalization", "kwarg_rename", "client_only", + } allowed_in_sig = excl.kind in sig_mismatch_kinds if name in sdk_params and not allowed_in_sig: stale.append( diff --git a/tests/test_markets.py b/tests/test_markets.py index 638d470..bbfdac3 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -202,6 +202,34 @@ def test_auto_paginates(self, markets: MarketsResource) -> None: assert tickers == ["A", "B", "C"] assert route.call_count == 2 + @respx.mock + def test_max_pages_caps_fetch(self, markets: MarketsResource) -> None: + """Public ``list_all`` forwards ``max_pages`` to ``_list_all`` (#98).""" + counter = {"n": 0} + + def _make_response(request: httpx.Request) -> httpx.Response: + counter["n"] += 1 + n = counter["n"] + return httpx.Response( + 200, + json={"markets": [{"ticker": f"M-{n}"}], "cursor": f"cur-{n}"}, + ) + + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + side_effect=_make_response + ) + tickers = [m.ticker for m in markets.list_all(max_pages=2)] + + assert route.call_count == 2 + assert tickers == ["M-1", "M-2"] + + def test_max_pages_zero_rejected(self, markets: MarketsResource) -> None: + """``max_pages=0`` raises ValueError at the public boundary (#98).""" + with pytest.raises(ValueError, match=r"max_pages must be positive"): + # The validator runs eagerly inside list_all, before the + # generator is consumed — no list() needed. + markets.list_all(max_pages=0) + @respx.mock def test_list_all_with_all_new_filters(self, markets: MarketsResource) -> None: """v0.7.0 ADDs on list_all match list (no cursor)."""