diff --git a/CHANGELOG.md b/CHANGELOG.md index 515a1a4..4be9da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ All notable changes to kalshi-sdk will be documented in this file. ### Changed +- **All response models uniformly use `extra="allow"` (#114).** Previously + 5 response models (`Page`, `Orderbook`, `OrderbookLevel`, + `BidAskDistribution`, `PriceDistribution`) fell back to Pydantic's default + `extra="ignore"`, silently dropping unknown fields — while the 80 sibling + response models exposed them via `__pydantic_extra__`. Instances of those + 5 classes will now preserve unknown fields. Request bodies remain + `extra="forbid"` (unchanged). - **Count/size/volume response fields retyped from `DollarDecimal` to `FixedPointCount` (#90).** Fields with `_fp` wire aliases were annotated as `DollarDecimal` — the type that signals "dollar amount." Runtime diff --git a/kalshi/models/common.py b/kalshi/models/common.py index 91940a1..f5cd369 100644 --- a/kalshi/models/common.py +++ b/kalshi/models/common.py @@ -29,6 +29,7 @@ class Page(BaseModel, Generic[T]): items: list[T] cursor: str | None = None + model_config = {"extra": "allow"} @property def has_next(self) -> bool: diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index 64cbd8d..b50561b 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -148,6 +148,8 @@ class OrderbookLevel(BaseModel): price: DollarDecimal quantity: FixedPointCount + model_config = {"extra": "allow"} + class Orderbook(BaseModel): """Orderbook for a market.""" @@ -156,6 +158,8 @@ class Orderbook(BaseModel): yes: NullableList[OrderbookLevel] = [] no: NullableList[OrderbookLevel] = [] + model_config = {"extra": "allow"} + class BidAskDistribution(BaseModel): """OHLC data for bid/ask prices within a candlestick period.""" @@ -177,7 +181,7 @@ class BidAskDistribution(BaseModel): validation_alias=AliasChoices("close_dollars", "close"), ) - model_config = {"populate_by_name": True} + model_config = {"extra": "allow", "populate_by_name": True} class PriceDistribution(BaseModel): @@ -203,7 +207,7 @@ class PriceDistribution(BaseModel): validation_alias=AliasChoices("close_dollars", "close"), ) - model_config = {"populate_by_name": True} + model_config = {"extra": "allow", "populate_by_name": True} class Candlestick(BaseModel): diff --git a/tests/test_model_extra_policy.py b/tests/test_model_extra_policy.py new file mode 100644 index 0000000..adbf6e6 --- /dev/null +++ b/tests/test_model_extra_policy.py @@ -0,0 +1,69 @@ +"""Enforce a uniform ``extra=`` policy across response models (#114). + +Response models use ``extra="allow"`` so the SDK is forwards-compatible when +Kalshi adds new fields. Request bodies use ``extra="forbid"`` so the +client-side phantom-key check catches user typos at call time. +""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +import kalshi.models + +# Request bodies must stay `extra="forbid"`. Identified by name suffix per +# the CLAUDE.md "Adding a new resource" convention. Both `Request` and the +# longer `RequestOrder` are needed: `BatchCancelOrdersRequestOrder` is a +# request-body sub-model whose name doesn't end in plain `Request`. +_REQUEST_BODY_SUFFIXES = ("Request", "RequestOrder") + + +def _exported_model_classes() -> list[tuple[str, type[BaseModel]]]: + """Every BaseModel subclass re-exported from ``kalshi.models.__all__``. + + Scope assumption: this only enforces the policy on models exported via + ``__all__``. A response model defined in a ``models/*.py`` file but + never wired into ``__all__`` would silently escape the check. The + project convention is to export every model; new models added without + exporting them fall outside this guard by design. + """ + out: list[tuple[str, type[BaseModel]]] = [] + for name in sorted(kalshi.models.__all__): + obj = getattr(kalshi.models, name) + if isinstance(obj, type) and issubclass(obj, BaseModel): + out.append((name, obj)) + return out + + +def _is_request_body(name: str) -> bool: + return any(name.endswith(suffix) for suffix in _REQUEST_BODY_SUFFIXES) + + +_ALL_MODELS = _exported_model_classes() +_RESPONSE_MODELS = [(n, c) for n, c in _ALL_MODELS if not _is_request_body(n)] +_REQUEST_MODELS = [(n, c) for n, c in _ALL_MODELS if _is_request_body(n)] + + +@pytest.mark.parametrize( + "name,cls", _RESPONSE_MODELS, ids=[n for n, _ in _RESPONSE_MODELS], +) +def test_response_models_use_extra_allow(name: str, cls: type[BaseModel]) -> None: + cfg = getattr(cls, "model_config", {}) + extra = cfg.get("extra") + assert extra == "allow", ( + f"Response model {name} has extra={extra!r}; expected 'allow' so the " + f"SDK is forwards-compatible when Kalshi adds new fields (#114)." + ) + + +@pytest.mark.parametrize( + "name,cls", _REQUEST_MODELS, ids=[n for n, _ in _REQUEST_MODELS], +) +def test_request_bodies_use_extra_forbid(name: str, cls: type[BaseModel]) -> None: + cfg = getattr(cls, "model_config", {}) + extra = cfg.get("extra") + assert extra == "forbid", ( + f"Request body {name} has extra={extra!r}; expected 'forbid' so " + f"phantom kwargs fail at call time (CLAUDE.md key convention)." + )