fix(codegen): keep required nullable response fields nullable - #1143
Conversation
There was a problem hiding this comment.
Ladon verdict: Approve
Approve
Clean, well-tested codegen fix. The custom response-arm emitter now widens a required property with | None only when its schema permits null, so CreateMediaBuySuccess.confirmed_at deserializes the null its own schema allows while remaining required (no default). The one-line generated diff is regeneration output paired with the emitter change — not a hand-edit — consistent with the "generated code is not source" rule. The fix: prefix is correct: the change is corrective and makes deserialization strictly more permissive (does not remove/rename a public export, flip required↔optional in a break-inducing way, or change wire shape so existing buyers stop deserializing), so no breaking-semver signal is required. The pre-existing exclude_none=True serialization gap is explicitly and correctly scoped out.
No blocking or Medium findings. high_risk is false, gated_paths is false, no author team gate. Rows 1–8 do not fire → row 9 approve.
The custom response-arm emitter in scripts/post_generate_fixes.py read a schema's `required` list as "not Optional" and never consulted the declared `type` array, so a property typed `["string", "null"]` and listed in `required` lost its null branch. `CreateMediaBuySuccess.confirmed_at` is the live case: a buy still in `pending_creatives` has no seller commitment to report, and the schema says so by permitting null while still requiring the key — but the generated model rejected it. `required` and nullability are independent axes in JSON Schema. The emitter now widens a required property with `| None` when (and only when) the schema permits null, and does so without adding a default, so the field stays required on the wire. datamodel-code-generator and the versioned stub generator already got this right; the custom emitter was the outlier. The public alias `adcp.types.CreateMediaBuySuccessResponse` is a runtime `_canonical_clone`, so type checkers read it from canonical_creative.pyi. That stub left `confirmed_at` undeclared with an `Any` constructor argument, which hid the contract from adopters entirely; it now declares the field and the constructor parameter as `datetime | None`. The blanket `exclude_none=True` in `AdCPBaseModel.model_dump`, which drops a legitimately-null required key on serialization, is a separate concern and is deliberately untouched here. Closes #1137
17198e6 to
229dc74
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Clean pass. This is a spec-conformance fix for #1137: the custom response emitter now keeps required-and-nullable fields nullable (confirmed_at: AwareDatetime | None, no default) via bracket-aware, idempotent widening helpers. The generated artifact and .pyi stub were updated to match, with thorough runtime and type-check coverage. The datetime -> datetime | None widening is fix-appropriate (deserialization broadens, requiredness is unchanged, migration note present in README and PR body), so the fix: prefix is correct — no breaking semver signal is required.
No Critical, High, or Medium findings. Not gated (gated_paths: false), not high-risk, no no-auto-approve team match. None of decision rows 1–8 fire, so this falls through to row 9 → approve. Consistent with the prior clean approve.
Problem
media-buy/create-media-buy-response.jsontypesconfirmed_atas["string", "null"]and lists it in the success branch'srequired. In JSON Schema those are independent axes: the key must be present, and its value may be null. A buy still inpending_creativeshas no seller commitment instant to report, so null is a real protocol state, not a theoretical one.The custom response-arm emitter in
scripts/post_generate_fixes.py(restore_response_variant_aliases) readrequiredas "not Optional" and never consulted the declaredtypearray, so it emitted:Notably,
datamodel-code-generator(seecore/media_buy.py,media_buy/get_media_buys_response.py) andscripts/generate_versioned_stubs.py(seesrc/adcp/types/v32.pyi) both already get this right — the custom emitter was the outlier, and its rule would narrow any future required-and-nullable field the same way.Fix
scripts/post_generate_fixes.py_schema_permits_null()— recognizes both spellings the pinned schemas use: thetype: [..., "null"]array form and aoneOf/anyOfbranch of{"type": "null"}._union_with_none()/_top_level_union_parts()— bracket-aware widening that will not duplicate an existing top-levelNone(sodict[str, str | None]widens correctly andstr | Noneis left alone).| Nonewhen — and only when — the schema permits null, without adding a default. The field stays required on the wire; it just can hold the null the schema allows. Optional properties route through the same helper, which is a no-op for every property in the current tree.The fix lives in the generator, so it survives re-runs; no generated file was hand-edited.
src/adcp/types/canonical_creative.pyiThe public alias
adcp.types.CreateMediaBuySuccessResponseis a runtime_canonical_clone, so type checkers read it from this hand-maintained stub rather than from the model. The stub leftconfirmed_atundeclared as a field and typed the constructor parameterAny, so the contract was invisible statically even after the codegen fix (reading the attribute was anattr-definederror; passingNonewas unchecked). It now declaresconfirmed_at: datetime | Noneboth as a field and as the constructor argument.Regenerated artifact
Only
src/adcp/types/generated_poc/media_buy/create_media_buy_response.pychanged — one line:A sweep of every schema the emitter owns found
confirmed_aton thecreate_media_buysuccess arm to be the only required-and-nullable property in scope.core/registry-feed-response.json'scursor(the other case named in the issue) is emitted bydatamodel-code-generator, which already producesUUID | Nonewith no default — it needed no change.Tests
tests/test_code_generation.py— unit coverage for_schema_permits_null/_union_with_none; a regeneration test that runs the emitter into atmp_pathtree and asserts (via AST, against the pinned schema) thatconfirmed_atisAwareDatetime | Nonewith no default while the required non-nullable siblingmedia_buy_idis untouched; plus a guard that the committed generated tree carries the fix.tests/test_create_media_buy_response_types.py— runtime:confirmed_at=Nonevalidates, the field is still required (omitting it raises), a committed timestamp still parses and a malformed one still fails, and the null round-trips throughmodel_dump(exclude_none=False)→model_validate.tests/type_checks/required_nullable_response_fields.py— mypy--strictfixture, zerotype: ignore, covering both adopter surfaces: the publicadcp.types.CreateMediaBuySuccessResponse(field read,is Nonenarrowing, and construction with bothNoneand a real timestamp) and the generatedLegacyCreateMediaBuyResponse1behind it. Verified as negative controls: reverting the stub produces 7 mypy errors, reverting the generated file produces 3.Out of scope
AdCPBaseModel.model_dumpstill defaults toexclude_none=True, so a serialized nullconfirmed_atis dropped unless the caller passesexclude_none=False. Reconciling that blanket default with required-and-nullable fields is the issue's second-order note — a breaking serialization change with a much wider blast radius — and is deliberately untouched here.Checks run locally
make lint,make typecheck-all(mypysrc/, mypy--strict tests/type_checks/, type-ignore contract),make validate-generated, and the fullpytest tests/suite: 7688 passed, 42 skipped, 1 xfailed. All pre-commit hooks pass.Closes #1137
🤖 Generated with Claude Code
Open workspace in Conductor