diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1c4b2..63dc9d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/kalshi/models/events.py b/kalshi/models/events.py index d06e206..738cddc 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -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] = [] @@ -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). diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 524a238..5cd9459 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -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 @@ -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", + ), } diff --git a/tests/test_contracts.py b/tests/test_contracts.py index fbff578..d7dff0c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -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." diff --git a/tests/test_models.py b/tests/test_models.py index 5a11c2a..989378a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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``.""" @@ -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``.