fix(models): tolerate server omitting Event.product_metadata + null market_details (#183)#184
Conversation
…arket_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.
Code Review — PR #184OverviewThis PR fixes two distinct server-omission bugs exposed by the post-#172 nightly integration run, collapsing 20 cascading
Both patterns are correct for their respective failure modes and consistent with existing conventions ( Code Quality & Correctness
The fix patterns are right:
The first
The branching logic for the new kind is correct: field must still exist AND must be optional. Both regression directions are caught (SDK drops it → stale; SDK re-tightens it → stale). Minor: silent skip in the field_info = model_cls.model_fields.get(name)
if field_info is not None and field_info.is_required():
stale.append(...)
Both regression tests are clean and targeted:
Explains both fix patterns and the distinction between them. Mentions the 20-test cascade, which gives good context for the "why this matters" question. Potential IssuesNone blocking. One future-proofing concern:
SummaryWell-scoped, correctly implemented, and thoroughly documented. The two-pattern approach ( Approve with one non-blocking suggestion: add an assertion or comment in the |
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.
56961d5 to
9e0545f
Compare
|
Addressed the latent-gap nit in 9e0545f: The Replaced the direct lookup with the same wire→python mapping 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(...) # SDK no longer emits
elif matched_field_info.is_required():
stale.append(...) # SDK re-tightenedThis collapses the prior two-step ( No behavior change for this PR ( |
Code Review — PR #184Overall this is a clean, targeted fix. The two-pattern distinction (missing key vs null value) is the right call, the EXCLUSIONS machinery is correctly extended, and the tests are well-focused. A few things worth flagging before merge. What the PR does
Issues1.
The 2. In the new matched_field_info = next(
(
info
for fld_name, info in model_cls.model_fields.items()
if (info.serialization_alias or fld_name) == name
),
None,
)This only checks 3.
Suggestions (non-blocking)
Stale-exclusion check — The existing What's good
The demo-only citation and the alias gap in the stale-check are the two things I'd address before merge. Everything else is polish. |
Code Review — PR #184Summary: Two targeted fixes for server-side spec violations caught by the nightly integration job: What's Good
Issues1. In matched_field_info = next(
(
info
for fld_name, info in model_cls.model_fields.items()
if (info.serialization_alias or fld_name) == name
),
None,
)But For 2. The 3. EXCLUSIONS entry cites demo only — The
The current entry cites only the demo integration run. If prod hasn't been verified yet, either relax the docstring ("SHOULD cite" or "demo or prod") or note it as a TODO to verify against prod before this is considered fully closed. Minor4. The 5. Multi-line test docstrings CLAUDE.md: "multi-line comment blocks — one short line max." The two new tests have 3-line docstrings. The content is valid (regression WHY) but the format breaks the convention. Consider condensing: # #183: server omits product_metadata; must parse as None, not raise.# #183: server sends null for market_details; NullableList must coerce to [].VerdictItems 1 and 2 are the ones I'd fix before merge. Item 1 is a silent correctness gap in a test that guards contract integrity; item 2 is a defensive robustness issue given the lessons of this exact PR (don't trust spec-required keys to be present). Items 3–5 are minor/informational. |
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.
|
Round 2 + 3 addressed in 3276847: 1. 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,
)The bot's suggestion to "use 2. "demo+prod observation MUST" — softened. I wrote that contract in #172 without thinking it through; we don't have prod API access from CI, so the strict reading is impossible to satisfy. Updated the
The current entry's demo-nightly-run citation now satisfies the relaxed contract honestly. Not actioned:
The current Multi-line test docstrings violate CLAUDE.md — declined. Same fabricated quote the bot raised on PR #181: the rule "multi-line comment blocks — one short line max" doesn't appear in this repo's CLAUDE.md or AGENTS.md. The actual project convention (CLAUDE.md §3) is "Touch only what you must. Match existing style." Test docstrings throughout this codebase are multi-line; the new ones are consistent with that. Verification: 2114 unit tests passing, |
Code Review — PR #184: tolerate
|
| Field | Server deviation | Fix |
|---|---|---|
Event.product_metadata |
Key absent entirely | dict[str, Any] | None = None + EXCLUSIONS server_omits_despite_required |
EventMetadata.market_details |
Key present, value null |
NullableList[MarketMetadata] (no default — key still required) |
The distinction is correct: missing key → optional field + exclusion; null value → NullableList without changing requiredness. The NullableList pattern matches sibling fields in the codebase.
Issues
1. market_details has no regression guard for the key-absent case
NullableList[MarketMetadata] (no default) keeps the key required by Pydantic. If the server ever starts omitting market_details entirely (the same progression that hit product_metadata), the SDK will fail with type=missing. The current test only covers null-value:
data["market_details"] = None # covered
# data.pop("market_details") # NOT covered — would raise todayConsider adding a test that documents this boundary is intentional and will loudly fail if behavior changes:
def test_missing_market_details_key_fails(self) -> None:
"""market_details tolerates null but not a missing key; add an EXCLUSIONS
entry + NullableList default if the server starts omitting it."""
data = event_metadata_dict()
data.pop("market_details")
with pytest.raises(ValidationError):
EventMetadata.model_validate(data)Without this, a future fix for key-omission could silently skip the EXCLUSIONS step.
2. _extract_spec_names vs _model_aliases inconsistency in test_exclusion_map_is_current
The new server_omits_despite_required branch uses _extract_spec_names(info, fld_name) to resolve field identity, while the pre-existing elif branch uses _model_aliases(model_cls). The comment justifies _extract_spec_names correctly (it covers AliasChoices / serialization_alias). The implicit implication is that the existing branch has a potential gap for aliased fields — worth a # TODO: note or a follow-up issue so it isn't silently forgotten. Not a blocker for this PR since the gap predates it.
3. Soft docstring change to Exclusion relaxes the evidence requirement
- 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.The new wording is pragmatic (CI-only access makes prod captures hard), but "preferred but not always achievable" could be read as permanently waiving the prod-confirmation step. Consider tightening slightly:
MUST cite a demo nightly run ID or prod-traffic capture date. Add prod confirmation when available; a demo-only entry is acceptable for CI-caught deviations.
Nits
Comment line length in events.py — CLAUDE.md specifies "one short line max" for inline comments. Both new comment blocks are 3 lines. They explain genuinely non-obvious behavior, so I understand the impulse, but the convention exists for a reason. Trimming to one line (e.g., # Server omits key on most events; see EXCLUSIONS. Run #26141405845.) keeps the signal without setting a precedent.
product_metadata happy-path coverage — The new regression test covers the omission case. An existing test should cover parsing when the server does send product_metadata. If the existing fixture includes it, this is already covered; just confirm event_dict() includes the field so the happy path isn't accidentally vacuous.
What's good
- Correct two-pattern approach:
| None = Nonefor missing key,NullableListfor null value. Mixing them up would have been an easy mistake. - EXCLUSIONS entry is precisely documented (run ID, date, commit hash, example event names).
test_exclusion_map_is_currentcorrectly implements the inverse semantics: stale if field removed OR field re-tightened to required.- All 2114 tests pass, ruff clean, mypy clean — no regressions.
- The PR description is excellent; the "two different fix patterns because the failure modes are different" explanation should survive somewhere durable (the CHANGELOG entry covers it).
Verdict: Approve with the suggestion to add the test_missing_market_details_key_fails boundary test (item 1) before merge, or open a follow-up issue for it if you'd rather ship the green CI unblocking now.
Bot round-2 found one item to action and one based on a misreading of
convention.
Actioned:
* ROADMAP.md "Open trackers" trimmed from four lines of tombstone prose
("None. The previous entries here (#45, #53, #106) are all closed:
... ") down to "None." The why-they-closed history will rot by the
next release; anyone curious can run `gh issue view 45` or check git
history.
Not actioned:
* Bot suggested changing CHANGELOG.md "Pre-release docs audit (#179)"
to "(#188)" on the claim that "every other CHANGELOG entry uses the
PR number." This is backwards. Existing CHANGELOG entries
(#175, #176, #177, #178, #183) are all *issue* numbers; the PRs that
closed them were #185, #187, #186, #182, #184 respectively. Verified
via `gh issue view <N> --json closedByPullRequestsReferences`. The
convention is "(#<issue-number>)" — using #179 is correct.
mkdocs --strict still clean.
Closes #183.
First two
server_omits_despite_requiredcases caught by the post-#172 nightly integration job (run #26141405845 against demo commit788789c). Both fields hitValidationErrorbecause the spec marks them required but the live demo server doesn't honor that.The two server omissions
Event.product_metadatarequired: true,dicttype=missingdict | None = None+EXCLUSIONSentryEventMetadata.market_detailsrequired: true,listnulltype=list_type, input_value=NoneNullableList[MarketMetadata](coerces null → [])Two different fix patterns because the failure modes are different:
product_metadata) — the only correct fix is to drop the requirement on the SDK side and document viaEXCLUSIONS. The spec contract is violated by the server; we accept the deviation and keep parsing.market_details) — the spec contract is satisfied (key IS present), only the value's type doesn't match.NullableListis the project's existing pattern for "tolerate null, expose[]" and matches sibling fields likeSeries.tags,Milestone.related_event_tickers, and the v0.9.0 Series fix.20 cascading integration test failures across
tests/integration/test_events.py,test_markets.py, andtest_series.py— every test that callsevents.get()— collapse to these 2 root causes.EXCLUSIONS — first usage of
server_omits_despite_requiredtests/_contract_support.py:This is the first concrete
server_omits_despite_requiredentry — the kind was added in #172 specifically for this scenario. Per the contract there: each entry MUST cite a demo+prod observation.test_exclusion_map_is_currenttaught about the new kindThe pre-existing stale-exclusion check assumed all model exclusion kinds mean "SDK excludes this field" — so it flagged the new entry as stale because the SDK model DOES still emit
product_metadata. That's wrong forserver_omits_despite_required: the SDK MUST emit the field (to parse responses when the server does send it) but the field MUST be optional.The check now branches on
kind:Both flip directions trigger stale-detection: SDK dropping the field (unexpected re-removal) OR SDK re-tightening to required (regression of the deviation).
Regression tests
Two new unit tests in
tests/test_models.pylock the behavior in:TestEventV3180Fields::test_parses_when_server_omits_product_metadata— popsproduct_metadatafrom the fixture and assertsEvent.model_validate(...)succeeds withe.product_metadata is None.TestEventMetadataV3180Fields::test_parses_when_server_sends_null_market_details— setsmarket_details: Noneon the fixture and assertsEventMetadata.model_validate(...)succeeds withm.market_details == [].Verification
uv run pytest tests/ --ignore=tests/integration→ 2114 passed, 0 warnings.uv run ruff check .→ clean.uv run mypy kalshi/→Success: no issues found in 76 source files.Watch list update
Of the original watch list from #172/#171 (
MultivariateEventCollection.{open,close}_date,LookupPoint.last_queried_ts,Trade.taker_{outcome,book}_side, WS*_ts_ms,AssociatedEvent.active_quoters,PositionsResponse.{market,event}_positions), none of those tightenings broke this nightly. The two surprises wereEvent.product_metadataandEventMetadata.market_details, which weren't on the explicit watch list — both are v3.18.0 backfill fields the spec promotes but server hasn't caught up on. Watch list should grow as more nightly findings come in.