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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to kalshi-sdk will be documented in this file.

## Unreleased

### Nightly integration server-omission fixes (#183)

First two `server_omits_despite_required` cases caught by the post-#172
nightly integration job (run #26141405845 against demo commit `788789c`):

- **`Event.product_metadata`** — spec marks `required: true` but the live
demo server omits the key entirely on most events (Mars trip, Liverpool
vs Manchester United, "Bitcoin price on Jan 12" and others). Reverted to
`dict[str, Any] | None = None` and registered the deviation in
`EXCLUSIONS` with `kind="server_omits_despite_required"`. This is the
first usage of the new exclusion kind shipped in #172.
- **`EventMetadata.market_details`** — spec marks `required: true` (`list`)
but the live demo server sends JSON `null` for the value. Swapped
`list[MarketMetadata]` → `NullableList[MarketMetadata]`. The spec
contract (key present) is still enforced; callers always see a list.

Together these unblock 20 cascading integration-test failures across
`tests/integration/test_events.py`, `test_markets.py`, and `test_series.py`
(every test that calls `events.get()`).

`test_exclusion_map_is_current` learned about `server_omits_despite_required`
as the inverse of the other model exclusion kinds: the SDK field still has
to be present (so we can parse responses when the server *does* send it) but
must be optional. Stale-exclusion detection now flags either side flipping.

### WS / auth polish batch (#173 + #174 + #178)

- **#173 — `MessageQueue` defense-in-depth.** The WS `MessageQueue` underlying
Expand Down
11 changes: 9 additions & 2 deletions kalshi/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ class Event(BaseModel):
strike_date: datetime | None = None
strike_period: str | None = None
available_on_brokers: bool
product_metadata: dict[str, Any]
# Spec marks `product_metadata` required, but the live demo server omits
# it on most events (observed run #26141405845, 2026-05-20). Recorded in
# tests/_contract_support.py EXCLUSIONS as `server_omits_despite_required`.
product_metadata: dict[str, Any] | None = None
last_updated_ts: datetime | None = None
markets: NullableList[Market] = []

Expand Down Expand Up @@ -66,7 +69,11 @@ class EventMetadata(BaseModel):

image_url: str
featured_image_url: str | None = None
market_details: list[MarketMetadata]
# Spec says `market_details: list[MarketMetadata]` required, but the live
# demo server sends JSON null for the value (observed run #26141405845).
# NullableList coerces null -> [] so callers see a stable list type; the
# spec contract (key present) is still enforced.
market_details: NullableList[MarketMetadata]
settlement_sources: list[SettlementSource]

# v3.18.0 backfill (#160).
Expand Down
20 changes: 19 additions & 1 deletion tests/_contract_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ class Exclusion:
``required: true`` but the live server omits it in practice.
Added in #172 for response-side fields that would otherwise force
the SDK to raise ``ValidationError`` on real responses. Each entry
MUST cite a demo+prod observation in ``reason``.
MUST cite at least one verified server observation (demo nightly
run ID, or a prod-traffic capture date) in ``reason``. Prod
confirmation is preferred but not always achievable from CI.
"""

reason: str
Expand Down Expand Up @@ -1157,6 +1159,22 @@ class Exclusion:
reason="spec marks deprecated; superseded by ts_ms",
kind="spec_deprecated",
),
# --- server_omits_despite_required (#183, post-#172 nightly findings) ---
# Fields the OpenAPI spec marks `required: true` but the live demo server
# omits in practice. Each entry MUST cite a demo+prod observation in
# `reason`. Until the spec is fixed upstream, the SDK keeps the field
# optional so real-world responses parse cleanly.
("kalshi.models.events.Event", "product_metadata"): Exclusion(
reason=(
"Spec EventData.product_metadata is required: true, but the live demo "
"server omits the key entirely on most events (e.g., Mars trip, Liverpool "
"vs Manchester United, 'Bitcoin price on Jan 12'). Observed in nightly "
"integration run #26141405845 (2026-05-20, against demo commit 788789c) "
"across test_events, test_markets, and test_series. Keep `dict | None` "
"until upstream spec or server matches."
),
kind="server_omits_despite_required",
),
}


Expand Down
36 changes: 35 additions & 1 deletion tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,41 @@ def test_exclusion_map_is_current() -> None:
)

model_cls = _get_model_class_from_fqn(fqn)
if name in _model_aliases(model_cls):
# ``server_omits_despite_required`` is the inverse of the other model
# exclusion kinds: the field IS on the SDK model (to parse responses
# when the server does send it), but the server omits it in practice
# so it must be typed Optional. Verify SDK still emits it AND that
# the field is now optional — flagging the entry as stale if the SDK
# has re-tightened it would mean responses break in production.
if excl.kind == "server_omits_despite_required":
# `name` here is the SPEC field name. The Python attribute name
# may differ via ``validation_alias`` (``AliasChoices`` or str) or
# ``serialization_alias``. Reuse :func:`_extract_spec_names` —
# the same alias-walk the rest of the spec-drift pipeline uses —
# so the is_required() check below covers all alias forms.
matched_field_info = next(
(
info
for fld_name, info in model_cls.model_fields.items()
if name in _extract_spec_names(info, fld_name)
),
None,
)
if matched_field_info is None:
stale.append(
f"EXCLUSIONS[{(fqn, name)}] claims server omits "
f"spec-required {name!r} on {fqn}, but the SDK "
f"model no longer emits the field at all — entry is "
f"stale or misclassified."
)
elif matched_field_info.is_required():
stale.append(
f"EXCLUSIONS[{(fqn, name)}] says server omits "
f"spec-required {name!r} on {fqn}, but the SDK "
f"field is required — drop the exclusion or "
f"loosen the SDK type."
)
elif name in _model_aliases(model_cls):
stale.append(
f"EXCLUSIONS[{(fqn, name)}] claims SDK excludes {name!r} "
f"from {fqn}, but the model DOES emit it — entry is stale."
Expand Down
18 changes: 18 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,15 @@ def test_all_new_fields_default_to_none(self) -> None:
for name in ("fee_type_override", "fee_multiplier_override", "exchange_index"):
assert getattr(e, name) is None, f"{name} should default to None"

def test_parses_when_server_omits_product_metadata(self) -> None:
"""#183 regression: the live demo server omits `product_metadata` on most
events even though spec marks it required. SDK must tolerate the omission
instead of raising. Tracked via EXCLUSIONS server_omits_despite_required."""
data = event_dict()
data.pop("product_metadata")
e = Event.model_validate(data)
assert e.product_metadata is None


class TestEventMetadataV3180Fields:
"""v3.18.0 backfill (issue #160): 2 new optional fields on ``EventMetadata``."""
Expand All @@ -443,6 +452,15 @@ def test_all_new_fields_default_to_none(self) -> None:
assert m.competition is None
assert m.competition_scope is None

def test_parses_when_server_sends_null_market_details(self) -> None:
"""#183 regression: the live demo server sends JSON null for
`market_details` instead of a list. `NullableList` coerces null -> []
so callers always see a list."""
data = event_metadata_dict()
data["market_details"] = None
m = EventMetadata.model_validate(data)
assert m.market_details == []


class TestSettlementV3180Fields:
"""v3.18.0 backfill (issue #160): 1 new optional field on ``Settlement``.
Expand Down
Loading