From 7b6a9444e2a16d67c098e835c4e1c8ba07265409 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Wed, 20 May 2026 05:07:13 -0500 Subject: [PATCH 1/3] fix(models): tolerate server omitting Event.product_metadata + null market_details (#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 required: true, but demo server omits the key entirely on most events. Reverted to dict | None = None + EXCLUSIONS entry citing the demo run. First usage of the server_omits_despite_required exclusion kind shipped in #172. EventMetadata.market_details: spec required: true (list), but demo server sends JSON null. Swapped list[MarketMetadata] -> NullableList[MarketMetadata]; spec contract (key present) still enforced, callers always see a list. Together these unblock 20 cascading integration-test failures across test_events.py, test_markets.py, and test_series.py (every test that calls events.get()). test_exclusion_map_is_current now understands server_omits_despite_required as the inverse of the other model exclusion kinds: SDK field must remain on the model (to parse responses when the server does send it) but must be optional. Either side flipping triggers a stale-exclusion failure. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ kalshi/models/events.py | 11 +++++++++-- tests/_contract_support.py | 16 ++++++++++++++++ tests/test_contracts.py | 25 ++++++++++++++++++++++++- tests/test_models.py | 18 ++++++++++++++++++ 5 files changed, 92 insertions(+), 3 deletions(-) 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..7b25810 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1157,6 +1157,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..e96b319 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1448,7 +1448,30 @@ 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": + if name not in _model_aliases(model_cls): + 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." + ) + else: + field_info = model_cls.model_fields.get(name) + if field_info is not None and 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``. From 9e0545f629894f8de9553e2631522b4e394712af Mon Sep 17 00:00:00 2001 From: Jeff West Date: Wed, 20 May 2026 05:16:03 -0500 Subject: [PATCH 2/3] fixup: harden server_omits_despite_required check vs. wire-alias keys Bot caught a latent gap: when an EXCLUSIONS key uses the spec field name that differs from the Python attribute name (via serialization_alias), the is_required() check silently passed because model_fields.get(name) returned None. Doesn't affect this PR (product_metadata's Python name matches the EXCLUSIONS key). Fixed proactively: now resolves the FieldInfo via the same wire->python mapping that _model_aliases is the forward of, so the re-tightened check fires for future entries that legitimately key by a wire alias. --- tests/test_contracts.py | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index e96b319..bf7dc09 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1455,22 +1455,34 @@ def test_exclusion_map_is_current() -> None: # 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": - if name not in _model_aliases(model_cls): + # `name` here is the SPEC field name, which may differ from the + # Python attribute name when the field uses `serialization_alias`. + # Look up the FieldInfo via the same wire→python mapping that + # `_model_aliases` is the forward of, so the is_required() check + # below doesn't silently pass when the EXCLUSIONS key happens to + # be a wire alias. + matched_field_info = next( + ( + info + for fld_name, info in model_cls.model_fields.items() + if (info.serialization_alias or fld_name) == 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." ) - else: - field_info = model_cls.model_fields.get(name) - if field_info is not None and 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 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} " From 32768476d905a9b2c73be67557656fdfaf2fb264 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Wed, 20 May 2026 05:23:42 -0500 Subject: [PATCH 3/3] fixup: validation_alias support + soften prod-citation requirement Round-2 bot review on PR #184: 1. matched_field_info lookup missed validation_alias / AliasChoices. My round-1 fix only checked serialization_alias-or-field-name (same set _model_aliases returns). If a future server_omits_despite_required entry keys by a spec wire name accepted only via validation_alias, the lookup returned None and falsely reported the entry as stale. Swapped the bespoke comprehension for _extract_spec_names, which is the project's canonical wire-name resolver and walks AliasChoices, str validation_alias, and serialization_alias exhaustively. 2. EXCLUSIONS docstring required "demo+prod" citation; reality is we don't have prod API access from CI. Softened to "at least one verified server observation (demo nightly run ID, or a prod-traffic capture date)" with "Prod confirmation is preferred but not always achievable from CI". Not actioned: - "market_details should be NullableList = [] for defense-in-depth" declined. Per #172 policy, tightened NullableList fields don't carry defaults (matches Series.tags, Milestone.related_event_tickers). The bot's reasoning ("server might start omitting the key later") is speculative; we react to real failures via EXCLUSIONS, not hypothetical ones. The "key present, value null" failure mode is fixed; the hypothetical "key missing entirely" mode would surface in the next nightly and get its own EXCLUSIONS entry. - "Multi-line test docstrings violate CLAUDE.md" - the cited rule ("multi-line comment blocks - one short line max") doesn't exist in the project's CLAUDE.md or AGENTS.md. Same fabrication the bot raised on PR #181. Not changing. --- tests/_contract_support.py | 4 +++- tests/test_contracts.py | 13 ++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 7b25810..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 diff --git a/tests/test_contracts.py b/tests/test_contracts.py index bf7dc09..d7dff0c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1455,17 +1455,16 @@ def test_exclusion_map_is_current() -> None: # 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, which may differ from the - # Python attribute name when the field uses `serialization_alias`. - # Look up the FieldInfo via the same wire→python mapping that - # `_model_aliases` is the forward of, so the is_required() check - # below doesn't silently pass when the EXCLUSIONS key happens to - # be a wire alias. + # `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 (info.serialization_alias or fld_name) == name + if name in _extract_spec_names(info, fld_name) ), None, )