fix(models): tighten 226 spec-required fields to non-optional (#172)#180
Conversation
Drops `None` defaults on 226 fields across 34 response models (21 REST, 13 WS) where the OpenAPI v3.18.0 / AsyncAPI v0.14 spec marks them `required: true` but the SDK kept them `Optional[T] | None = None`. Soft-breaking at the response-parse boundary: if the live server omits a spec-required field the SDK previously returned `None`; it now raises `pydantic.ValidationError`. The wire shape itself is unchanged. - Promotes `test_required_drift` (REST) and `test_ws_required_drift` (WS) from `warnings.warn` to `pytest.fail` so future drift on these gates is CI-blocking. - Adds `ExclusionKind="server_omits_despite_required"` for fields the spec lies about (currently unused; available for nightly findings). - New `tests/_model_fixtures.py` with builder helpers (`market_dict`, `order_dict`, `fill_dict`, etc.) producing complete spec-shaped wire dicts so test fixtures don't break. Closes #172
Code Review — PR #180: Tighten 226 spec-required fields to non-optionalOverviewThis PR closes a real correctness gap: 226 fields that the OpenAPI/AsyncAPI specs mark The scope is large but the pattern is uniform and mechanical. Architecture and conventions are sound throughout. Strengths
Issues and Observations1. Fields like pos = MarketPosition(ticker="MKT-A") # worked beforewill now get a 2. Dollar fixture values use In "fee_cost": 0, # intOther dollar fields across all fixtures use 3.
"created_time": "2026-01-01T00:00:00Z",✓ Good, this is correct. 4. These are promoted from 5. Cosmetic reformatting in Several Test Coverage
Missing: no test asserting that the Security / PerformanceNo security concerns. No performance concerns — field requiredness is resolved at class-definition time by Pydantic, not at parse time. SummaryCorrectness: solid. The two-flavor treatment of required fields ( Approve with the note to watch |
- Revert cosmetic-only reformatting in tests/_contract_support.py (formatter collapsed multi-line continuations on lines unrelated to the ExclusionKind addition). Only the new "server_omits_despite_required" entry + its docstring section remain. - Normalize dollar-string fixtures: settlement_dict.fee_cost was an int 0 instead of "0.0000"-style DollarDecimal wire shape; same for fill_payload_dict.fee_cost. Both fields are DollarDecimal on the SDK side, so the int worked via coercion but didn't match the wire conventions used elsewhere in the file.
|
Thanks for the review. Addressed in c175fa7:
Not actioned (intentional):
|
Code Review — PR #180: fix(models): tighten 226 spec-required fields to non-optionalThis is a high-quality correctness improvement. The SDK was silently lying to callers about which fields are guaranteed by the wire format; this PR aligns Python types with the OpenAPI/AsyncAPI Summary+1 to merge with the minor notes below. Nothing here is blocking, but a couple of items are worth considering before landing. Strengths
Issues1. Empty
|
- Replace 7 \`pass\` test stubs with real ValidationError negative tests (tests/ws/test_models.py: ticker_missing_required, ticker_extra_fields, trade_missing_required, multivariate_no_seq positive, multivariate_missing_required; tests/test_models.py: Trade.taker_outcome_side_missing, IncentiveProgram.incentive_description_missing). - Switch \`Market.price_ranges\` and \`MultivariatePayload.selected_markets\` to \`NullableList[T]\`. Kalshi has prior art of returning JSON null for required list fields; the original tightening would raise ValidationError rather than coerce to []. - Add 8 missing WS payload fixture builders: rfq_created, rfq_deleted, quote_created, quote_accepted, quote_executed, multivariate, market_lifecycle, order_group. Now all 13 tightened WS payloads have a corresponding builder in tests/_model_fixtures. - Add rationale comment on \`MarketLifecyclePayload.market_ticker\` documenting that the tightening was deliberate and the EXCLUSIONS escape hatch is the planned remediation if nightly catches a server emission without it.
|
Round 2 — all four addressed in 5868726: 1. Empty 2. 3. Eight missing WS builders — added. 4. Verification: full unit suite still Two nitpicks not actioned:
|
Code Review — PR #180: Tighten 226 spec-required fields to non-optionalOverviewThis PR correctly brings the SDK's type system into alignment with OpenAPI v3.18.0 / AsyncAPI v0.14 by removing The two-flavor treatment is implemented correctly throughout:
All Issues1.
# `market_ticker` is required per AsyncAPI v0.14 — tightened in #172.
# All observed lifecycle event types (`created`, `activated`,
# `deactivated`, `close_date_updated`, `determined`, `settled`,
# `metadata_updated`, `price_level_structure_updated`) carry it.
# If the server emits a market-scoped lifecycle event without it,
# the nightly integration job will catch the `ValidationError`; move to
# `EXCLUSIONS` with `kind="server_omits_despite_required"` at that point.
market_ticker: strCLAUDE.md says: "Default to writing no comments. Only add one when the WHY is non-obvious" and "Never write multi-line comment blocks — one short line max." The why here (spec-required, tightened in #172) belongs in the PR description (where it is), not in the source. Every other tightened field in this PR has no comment at all. This should either be dropped or collapsed to one line. 2.
markets: NullableList[Market] = []This is the same 3. Missing
4. The PR correctly flags these as prime Gate promotion — looks good# Before
warnings.warn(f"Required drift in {entry.sdk_model}:\n" + ...)
# After
pytest.fail(f"Required drift in {entry.sdk_model}:\n" + ...)The docstring update explaining the reason for the hard-fail ( Test coverage
SummaryActionable: Items 1–3 above. Item 1 (comment block) is a style violation against CLAUDE.md. Item 2 (Event.markets) is a correctness question that needs a quick check. Item 3 (missing fixture) is low-priority but worth adding for completeness. Not blocking: The core approach, field-by-field tightening, alias preservation, and the drift gate promotion are all correct. The |
- Collapse \`MarketLifecyclePayload.market_ticker\` rationale from a 6-line comment block to a single line. The full rationale lives in the PR description; the source comment just flags that the tightening was deliberate. - Add \`series_fee_change_dict\` builder to round out the fixture set. Was the one tightened model without a corresponding builder.
|
Round 3 — addressed in 6d5126c: 1. event_type: str # created/activated/deactivated/close_date_updated/determined/settled/etc
# #172: tightened; all observed lifecycle event_types carry market_ticker.
market_ticker: strNote: the CLAUDE.md quotes ("Default to writing no comments. Only add one when the WHY is non-obvious" / "Never write multi-line comment blocks — one short line max") don't appear in either CLAUDE.md or AGENTS.md in this repo. CLAUDE.md's actual rule is "Touch only what you must. … Match existing style." The previous review round asked for an explanatory comment here; this round asked for it to be shorter — collapsing is a fair landing zone. 2. 3. 4. Trade taker direction watch-list — no action needed, already in the PR watch list. Confirmed. Verification: full unit suite still |
Code Review — PR #180: Required-field drift closureOverall this is a well-scoped, well-documented correctness fix. The approach is sound and the test infrastructure changes are the right call. Notes below are a mix of risks to validate before merge and minor nits. What this PR does well
Risks to validate1. Watch-list fields are now parse-or-dieThe fields the PR author flags as likely first candidates for Specifically:
The nightly integration job is the right safety net, but it only catches these post-merge. Consider whether any of these warrant a pre-emptive 2.
|
WS reliability + auth polish batch on top of v2.2.0's spec-required tightening. See CHANGELOG.md for the full notes; high points: * WS resubscribe-window frame stashing — per-sid bounded stash closes a silent message-loss window during reconnect bursts (#176) * run_forever(stop_event=...) cooperative shutdown for SIGINT (#177) * run_forever() now raises KalshiSubscriptionError instead of silently returning when no subscribe_* has landed (#175) * Async RSA-PSS sign offload onto a dedicated 2-worker executor so signs don't queue behind getaddrinfo during reconnect storms (#178) * 226 spec-required fields tightened to non-optional with hard-fail drift gates (#172 via #180) * All 49→91 REST contract-map entries; completeness gate hard-fails (#171 via #181) * First two server_omits_despite_required exclusions for fields the live demo omits (#183) * MessageQueue maxlen defense-in-depth (#173) * _to_decimal_* consolidation into _coerce_decimal (#174) * Pre-release docs audit sweep across the full mkdocs site (#179) Soft-breaking at the response-parse boundary only (per #172): server omission of a previously-optional spec-required field now raises pydantic.ValidationError instead of silently producing field=None. Wire format unchanged. Verification: 2126 unit tests pass, ruff + mypy strict clean, mkdocs --strict clean.
Closes #172.
Summary
Drops
Nonedefaults on 226 spec-required Pydantic model fields across 34 response models (21 REST, 13 WS). The SDK now matches the OpenAPI v3.18.0 / AsyncAPI v0.14requiredset on the wire instead of lying about which fields are guaranteed.Promotes
test_required_drift(REST) andtest_ws_required_drift(WS) fromwarnings.warntopytest.fail, closing the regression class that allowed required-but-typed-Optional fields to drift unnoticed.Scope
Affected REST models (21, 136 fields):
Market(34),Order(17),Fill(13),MultivariateEventCollection(13),Settlement(9),Trade(8),Event(7),Series(7),MarketPosition(7),EventPosition(5),EventMetadata(3),Milestone(3),SportFilterDetails(2),IncentiveProgram(1),ApiKey(1),SeriesFeeChange(1),MarketCandlesticks(1),ScopeList(1),GetOrderGroupResponse(1),CreateOrderGroupResponse(1),CreateOrderRequest(1).Affected WS payloads (13, 90 fields):
UserOrdersPayload(18),FillPayload(14),TickerPayload(13),TradePayload(8),MarketPositionsPayload(7),QuoteExecutedPayload(7),QuoteCreatedPayload(6),QuoteAcceptedPayload(5),MultivariatePayload(4),RfqCreatedPayload(3),RfqDeletedPayload(3),MarketLifecyclePayload(1),OrderGroupPayload(1).Soft-breaking changes
The wire format is unchanged — the SDK type system just stopped lying. Three observable behavior changes:
pydantic.ValidationErroronmodel_validate(...)instead of returning a model with that field asNone. Server-omission patterns will be caught by the nightly integration job and migrated toEXCLUSIONSpost-merge.CreateOrderRequest.action. Direct model construction (CreateOrderRequest(ticker=..., side=...)) withoutactionnow raisesValidationError. The kwarg path (client.orders.create(ticker=..., side=...)) is unchanged — it still defaultsaction=None → "buy"at the resource layer.tests/_model_fixtures.py.Changes
Tightening
kalshi/models/**andkalshi/ws/models/**. Pattern:field: T | None = None→field: T;field: T | None = Field(default=None, **kw)→field: T = Field(**kw);field: NullableList[T] = []→field: NullableList[T];field: str = ""/field: bool = False/field: int = 0→ drop default.validation_alias/serialization_alias/ otherField(...)kwargs preserved verbatim.T(no default); fields whose spec type is nullable but spec marksrequired: truekeepT | Noneand drop the default — Pydantic still demands the key be present in the JSON, but the value may benull.Gate promotion (
tests/test_contracts.py)New
ExclusionKindtests/_contract_support.pyregisters a new kind:"server_omits_despite_required"For fields the spec marks
required: truebut the live server omits. Each entry MUST cite a demo+prod observation inreason. Currently unused; reserved for nightly-integration findings.Test fixture builders
tests/_model_fixtures.py(new) provides spec-shaped builders that produce complete wire dicts:23 builders covering every tightened model. Test-only — lives under
tests/, never shipped in the wheel.Verification
pytest tests/ --ignore=tests/integration— 2023 passed, 1 warning (the warning istest_contract_map_completenessfrom RESTCONTRACT_MAPmissing 42 SDK models — promotetest_contract_map_completenessto hard-fail #171, out of scope).pytest tests/test_contracts.py -W error::Warning -k 'required_drift'— 65 passed, hard-fail gate is live.ruff checkclean on every file touched by this PR.Migration
Market(ticker="X")outside of a server-response parse): must now pass all spec-required fields explicitly. Common-case real construction is viamodel_validate(server_json), which is unaffected; this only bites callers using positional/keyword constructors for partial values (e.g., quick test scaffolds, factory functions). Thetests/_model_fixturesbuilders are an option for tests but live undertests/and are not exported from the wheel — production callers in the same situation should keep a small local builder or pass required fields explicitly.tests/_model_fixturesbuilders, or pass all required fields manually.Optionalnarrowing (if order.outcome_side is not None: ...) can drop the guard;mypy --strictwill flag the now-redundant check.Out of scope
ValidationErrorsurfaced there moves the field toEXCLUSIONSwithkind="server_omits_despite_required"and a demo+prod citation.test_contract_map_completenessREST hard-fail — that's RESTCONTRACT_MAPmissing 42 SDK models — promotetest_contract_map_completenessto hard-fail #171.Watch list (per bot review)
Most likely first candidates for
server_omits_despite_requiredentries based on spec/server lag patterns:MultivariateEventCollection.open_date/close_date— collections with no fixed close date plausibly exist.Trade.taker_outcome_side/taker_book_side— newer v3.18.0 direction-encoding fields; server may not backfill historical trades.*_ts_msUnix-ms timestamps — newer companions to the older*_timeRFC3339 fields; server may not always send.