Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions kalshi/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions kalshi/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ class OrderbookLevel(BaseModel):
price: DollarDecimal
quantity: FixedPointCount

model_config = {"extra": "allow"}


class Orderbook(BaseModel):
"""Orderbook for a market."""
Expand All @@ -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."""
Expand All @@ -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):
Expand All @@ -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):
Expand Down
69 changes: 69 additions & 0 deletions tests/test_model_extra_policy.py
Original file line number Diff line number Diff line change
@@ -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)."
)
Loading