Repository navigation
Conversation
… forbids one The generator rendered every optional property as `T | None = None`, which admitted an explicit JSON `null` the pinned schema rejects. A pre-generation pass now grades each optional property with the bundled validator and marks the ones that refuse `null`; the first post-generation fix turns the mark into `adcp.types.base.NoExplicitNull`, appended to the field's `Annotated` metadata. The annotation, default and attribute access are unchanged. `NoExplicitNull` is a `json_or_python` core schema: the JSON side is the type without its null arm, so `model_validate_json` refuses `null` with the type's own error and `model_json_schema()` publishes no null arm; the Python side keeps `T | None`, so `Model(field=None)` and `model_validate` on a dict still read `None` as "not supplied", symmetric with `model_dump`'s `exclude_none` default. `ADCP_STRICT_VALIDATION=1` refuses a Python `None` too. Properties whose schema admits `null` are generated without the marker. Also: the eight submitted response arms drop a leftover `validate_default=True`; the three literal-widening fixes carry the marker; the parity suite grades the model in JSON mode and no longer declares the explicit-null divergence. Fixes #1547 Refs #1522, #1414, #1409, #1397, #1359, #1362 BREAKING CHANGE: a JSON document carrying `null` for an optional property whose schema does not admit `null` is now refused by `model_validate_json` and `TypeAdapter.validate_json`, and the published JSON Schema of such a property has no null arm. A Python `None` is still accepted. JSON produced with `exclude_none=False` must be read back in Python mode (`model_validate(json.loads(...))`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean breaking-change PR with correct semver signalling.
This fix(codegen)! change makes refusal of JSON null on optional-non-nullable properties explicit while preserving Python None as absence. The breaking change is correctly signalled (! prefix + BREAKING CHANGE: footer + migration memo), so release-please will cut a major — the mandatory semver check passes.
What was verified:
base.py_NoExplicitNullbuildsjson_or_pythonwith no validator in the path, preserving nested JSON diagnostics;_without_nullablecorrectly handles thejson_schema_input_schemaarm (the_forward_compattargeting-overlay bridge), tested.canonical_creative.py:321correctly short-circuitsNoExplicitNullin_has_boundary_serialization_metadata.post_generate_fixes.py(+418):_rewrite_no_explicit_null_marksL1249 correctly distinguishesT | NonefromLiteral[True]; black-layout emulation is fail-loud via CI--check.- Generated tree unchanged (
_generated.py/aliases.py/_ergonomic.py), no renumbering; import-layering, credential, and CI-gate floors intact.
No Critical/High/Medium findings. No gated paths, no high-risk flag, no author team gate. review_decision is REVIEW_REQUIRED but gated_paths is false, so no hard gate applies — falls through to row 9.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1547. Refs #1522, #1414, #1409, #1397, #1359, #1362.
What this changes
Every optional property the generator renders as
T | None = Nonealsoaccepted an explicit JSON
null, where the pinned schema — and so the SDK'sown
validate_request— rejects it. The generator now grades each optionalproperty with the same
jsonschemavalidator the bundled arm uses and, wherethe property cannot be
null, appends oneAnnotatedelement to the field:NoExplicitNull(inadcp.types.base, besideSchemaIntandWireUrl)builds a
json_or_pythoncore schema: the JSON side is the type without itsnull arm, the Python side keeps
T | None. No validator function sits inthe path, so nested validation keeps pydantic's native JSON handling
(duplicate-key diagnostics, error order), and the published JSON Schema
loses the null arm without a hook. The annotation, the default, and
attribute access are unchanged: omitted still reads as
Noneand dumps asabsent. What changes is input and the published schema:
model_validate_json('{..., "creatives": null, ...}')creatives is NoneValidationErrorat('creatives',)with the type's own error (list_type), as a required array givennullreportsmodel_validate({..., "creatives": None})/Model(creatives=None)Noneis "not supplied" (decision 1); refused underADCP_STRICT_VALIDATION=1model_validate({...})with the property omittedcreatives is Nonemodel_json_schema()["properties"]["creatives"]anyOf: [array, null]type: arraywith the bundle'sminItems/maxItems/descriptionmodel_dump()/exclude_none.pyistubsT | Nonenull(type list,anyOfnull arm, nullable$ref)nullIt is a generator rule, not per-field redeclarations: 9,044 fields across 667
generated modules (the issue's estimate was 9,945 across 687; the difference is
properties that already refused
None—Literalconstants with a default,fields an
allOfarm makes required — which only lose the transient mark). The canonical subclasses inherit it through the generated parent(
adcp.types.SyncCreativesRequestrefuses the same payload), and_inheritcarries the marker with the rest of the
FieldInfo.Mechanism
scripts/generate_types.py::mark_no_explicit_null_propertiesruns once overthe staged schema tree after every file is written (so
$refs resolve). Forevery property outside
requiredit asks the bundled validatoris_valid(None)on the property node — the same predicateadcp.types.versioned._schema_admits_nulluses foromit_none_fieldsonthe serialization side — and marks the ones that refuse with
x-adcp-no-explicit-null: true. The key never leaves the staging copy.Field(json_schema_extra=...)via--field-extra-keys(the only flag change).scripts/post_generate_fixes.py::point_omittable_fields_at_no_explicit_nullruns first in the fix list: it removes that keyword and appends
NoExplicitNull, re-rendering the field the way the generator's black passlaid it out (width from
[tool.black] line-length, right-hand-side split,hug, exploded). Graded: with the marker suppressed, the rewritten marked
tree is byte-identical to a tree generated without the mark, except for
one module (below). Every later fix therefore reads exactly the text it
read before, plus one
Annotatedelement.Why not #1522's approach
#1522 does not render this case. Measured on its head merged onto main +
#1548 + #1518 + #1501:
SyncCreativesRequest.creativesrenders identicallyto main and the #1547 payload is still accepted; its compiled constraints add
required-group validators, not null refusal. The "fork whose generator renders
optional-non-nullable distinctly" in the issue is later work on the
contributor's side that is not in the PR. The marker here is the smallest
mechanism that keeps the
T | Nonesurface; #1522's constraint compiler isthe right home for root-level conditions (#1414), not for this per-property
rule.
Decision memo for 9.0 GA
Decision 1 — a JSON
nullis refused; a PythonNonestill spells absencePydantic cannot tell
Model(**wire_dict)fromModel(field=None)written bya Python caller: both are python-mode validation. What it can tell apart is a
JSON document (
model_validate_json,TypeAdapter.validate_json,info.mode == "json") from a Python object. The rule is drawn there:"creatives": null(model_validate_json)list_typemodel_validate({... "creatives": None ...})(python mode)NoneNoneis "not supplied"Model(creatives=None)ADCP_STRICT_VALIDATION=1anyOf: [array, null]type: arrayWhy not refuse everywhere (the first cut of this PR did): measured on the
SDK's own suite, refusing a python-mode
Nonefails 428 tests, 316 ofthem with
null_not_allowed, through ~40 call sites insrc/adcpthatpass an optional value through as
field=Noneor build a wire dict withNonevalues and callmodel_validateon it (webhooks.py,webhook_sender.py,feed_mirror.py,client.py,server/principal.py,canonical_formats/*,decisioning/*,canonical_creative.py). That isidiomatic Python, it is what
model_dump'sexclude_none=Truedefaultalready promises on the way out, and every adopter's code does the same. A
GA line cannot break it. The schema's statement — absence or a value, never
null— is about the wire document, and that is where it is now enforced:the JSON arm, the JSON-mode model arm, and the published
inputSchemaagree.What this leaves open, stated plainly: the #1547 reproduction as written
(
model_validateon a Python dict carryingNone) still returnscreatives=None. The flip side of the same rule: JSON produced withnulls —model_dump_json(exclude_none=False), aTypeAdapterdump, oran adopter's own outer pydantic model wrapping an SDK model (
TaskResultis one) — is not an AdCP document and no longer parses in JSON mode. The
bundled schema already rejects such a document today; the model arm now
agrees. Reading one back still works through python mode
(
model_validate(json.loads(...))), and the SDK's own JSON-mode readers(
reporting/adjustment_evidence.py,reporting/ledger/provisional.py)parse documents the SDK dumped with its
exclude_nonedefault. Two teststhat round-tripped
TaskResult.model_dump_json()/TypeAdapter.dump_jsonnow dump with
exclude_none=True, the way the CLI already does. The same bytes as JSON are refused; the server frameworkalready runs
validate_requeststrict on inbound documents, so a wirenullnever reached a handler through the SDK's own servers; and the parity suite
now grades the model in JSON mode, which is the document the bundled schema
grades. The one-line alternative — refuse in python mode unconditionally — is
python_schema=strictunconditionally inadcp.types.base._NoExplicitNull.__get_pydantic_core_schema__, and themeasurement above is the cost. If the maintainers would rather take that cost for GA, the ~40 SDK
sites need the same treatment first (omit the key instead of passing
None),which touches files other workspaces own (
webhook_sender.py, reporting).ADCP_STRICT_VALIDATION=1— the existing switch that turns unknown fieldsinto errors — now also refuses a python-mode
None, so a suite that wants tocatch callers spelling absence as an explicit
Nonecan.One generated change beside the marker: the eight "submitted" response arms
carried
model_config = ConfigDict(extra='allow', validate_default=True),a leftover from when they paired it with
use_enum_valuesto turn thestatusdefault into its value.statusis aStrEnummember now and thedefault is already the literal's member, so validating defaults did nothing
— except that in JSON mode it would run every inherited
= Nonedefaultthrough
NoExplicitNull. The arms take the base's default policy.Decision 2 — the one module whose class set moves
brand/get_brand_identity_response.py: six colour properties areoneOf: [string-with-pattern, array-of-same]. The generator copies aproperty's extras onto the
RootModelit mints for an inline arm, so the markstops
--reuse-modelfrom folding the six identical arm wrappers into one.After the mark is stripped they are identical again but separately defined
(
Secondary1Item(Primary1Item)instead of(Primary)). No public name, nofield, no accepted document changes. Accepted rather than special-cased: the
alternative (not marking union-typed properties with inline arms, 23 nodes)
would leave those properties accepting
null.The six issues against the 9.0 GA line
maintodayT | Nonekept;NoExplicitNullappended)nullthe schema refuses; nothing newly acceptedMODEL_ACCEPTSdivergence class the parity suite could not closeformat: urienforced, strict scalars) — re-measured:https://and"sandbox": "yes"are refused by both arms.tests/conformance/test_schema_model_parity.pyis the bundle-driven sweep it asked for; this PR deletes itsoptional_field_accepts_explicit_nulldeclaration, leavingdatetime_field_parses_a_number,conditional_subschema_is_not_generated,boolean_literal_equals_one,union_arm_absorbs_a_type_error,canonical_model_refuses_legacy_creative_identity, open format kindsdatetimenumber parsing,Literal[True] == 1) as one small follow-up fornextRootModelwrappersTargetingOverlayInput/TargetingOverlaydiffer on 9 of 37 fields, all in the issue's two carve-outs (Sequencevsliston*_exclude, per-module element classes);geo_countriesis alist, indexable;ProductPurchaseInputdiffers fromProductPurchaseon 2 shared fields (was 19); 87RootModelclasses remain in the treeanyOfconditions droppedenforce_root_required_groupskeeps only each arm'srequiredCreateMediaBuyRequestwould refuse a doc no arm accepts)next, via #1522's constraint compiler, which is where it is implemented; do not hand-write the 12 validators onmainCreateMediaBuyResponse1)nextwith a deprecation shim; a GA line cannot rename 38 exportsrevalidate_instances="subclass-instances"closes the runtime holedocs/extending-types.mdand keep the config guard#1522 evaluation
Rebased (scratch, not pushed) onto
main5ce9ee4 + #1548 + #1518 + #1501:11 conflicting files, all two implementations of one fix. Resolution: keep
main's
_inheritform ofSyncCreativesRequest.creatives, take #1522'sgenerator, regenerate. After regeneration
generate_types.py --checkpasses,ruffandmypy src/adcp/are clean,mypy --strict tests/type_checks/isclean (the PR's documented 3.12
assert-typefailure does not reproduce), andthe suite is 17,251 passed / 0 failed / 2,655 skipped (Postgres lane not
exercised).
Redundant with main now:
pixel_ratiocomparison (#1518), open assetunions (#1501 — #1522's copy of
emit_open_asset_unionsis kept, #1501'sduplicate dropped), optional
creatives(#1546/#1548), the assetforward-compat patch removal (#1501).
Still unique to #1522, by theme (hand-written src +3.5k/−1.1k, scripts
+6.3k/−1.8k, tests +11.5k, generated 252 files):
schema_constraints.py,compile_conditions.py,_conditions.py): rootanyOf/if-then/required-group constraints compiled into validators; modules with@model_validator60 → 225nextbranch_classes.py,_split_root.py,_branch_dispatch.py,union_branch_names.py): construction dispatches to a branch; +6*Branchesnames inadcp.types, +43 in_generated; removesadcp.types.response_dispatch/ResponseArmDispatchMixin; deletes_ergonomic.pyandgenerate_ergonomic_coercion.pytype(x)changes for split rootsnext(also where #1409 lands)error.fieldJSONPath-lite (pydantic_errors.py,schema_validator.py,error_narrowing.py)mainas its own PR if the spec requires the path form for 3.2governance.py,server/*)canonical_formats/identity.py+ 5 callers) — fixes anAttributeErrorin the v2→v1 projectionmain, small PRLegacy*re-exports inadcp.types.legacymain, small PRLegacy*namesAlso found:
post_generate_fixes.pyon the branch defines_ensure_typing_importtwice with different signatures (the later wins) — a PR defect, not a merge
artifact.
Suggested path for the contributor: retarget #1522 to
nextas-is (itmerges cleanly there once
nexttakes main), and carve two small PRs formainnow: (1) the v1-refs accessor, (2) theadcp.types.legacyre-exports. If
error.fieldJSONPath is needed for 3.2 conformance, a third.Nothing in #1522 needs to precede 9.0 GA, and nothing in it conflicts with
this PR (its generator does not touch the optional-property rendering).
Not taken
for this fix; the pin stays. 0.81's changelog lists eight output-changing
breaks, so a bump is a regeneration with its own review on
next.Tests
tests/test_no_explicit_null.py(new): the Generated optional properties render asT | None, accepting an explicit JSON null the schema forbids #1547 document refused in JSONmode at the same pointer the bundled schema reports (
list_type); a PythonNonestillspells absence (dict and keyword) and dumps as absent;
ADCP_STRICT_VALIDATIONrefuses it too; constraints and descriptionsurvive beside the marker; the published schema drops the null arm and
keeps the bundle's
minItems/maxItems; a genuinely nullable property(
update_media_buy'sbudget_cap_timezone,daily_budget_cap) keepsaccepting
nullwith no marker; a JSON round trip of an omitted propertystays omitted; a redeclaration that keeps the parent
FieldInfokeeps therule; a 60-model random sample checks marker ⇔ no null arm; the transient
key ships nowhere; the rule reaches >500 modules and >8000 fields. One
test is
xfail(strict=True): the canonicaladcp.types.SyncCreativesRequestredeclares
creativeswith a freshField()onmain, which drops theinherited marker; fix(types): inherit the generated constraints on the canonical creatives field #1548's
_inheritcarries it, and the strict xfail flipsto a failure the moment that lands so the marker gets removed.
tests/test_post_generate_no_explicit_null.py(new): the rewriter's layoutagainst black's forms at the repository's line length, the marker-suppressed
mode, a
Literaldefault that only loses the keyword, and the parenthesiseddefault form. Beyond the unit tests, the rewrite was graded whole-tree: with
the marker suppressed, the rewritten marked generation is byte-identical to
an unmarked generation for 1096 of 1097 modules (the one exception is the
arm-wrapper dedup in
get_brand_identity_response.py, which a later fixrewrites anyway, so the committed module is unchanged).
tests/conformance/test_schema_model_parity.py: grades the model in JSONmode (the document under test is JSON), and the
optional_field_accepts_explicit_nulldeclaration is deleted — anull-acceptance divergence on any graded tool now fails the suite.
tests/test_targeting_overlay_compat.py,tests/test_targeting_overlay_schema.py:the schema-shape tests that expected an
anyOf: [TargetingOverlayInput, null]arm now expect the bundled shape (no null arm; oncontrol_media_buythe optional
packagesarray likewise loses its null arm, which moves oneadvertised path); the overlay field is optional, not nullable. The
targeting-overlay bridge in
_forward_compatpublishes its own inputschema, so
NoExplicitNullstrips the null arm throughjson_schema_input_schemaas well.tests/test_codegen_invariants.py,tests/test_canceled_literal_default.py:the three hand-written fixes that widen a
Literal[True] = TruetoLiteral[True] | None = None(canceled,unchanged, optional booleanconsts) now append
NoExplicitNulltoo — the parity suite caughtupdate_media_buy/canceled = nullbeing accepted otherwise.tests/test_manifest_response_readback.py: fixtures spell an absentformat_kindby omission rather thannull, and the two outer-modelround trips dump with
exclude_none=True(decision 1).tests/test_canonical_boundary_validation.py:canonical_creative._has_boundary_serialization_metadatatreats
NoExplicitNullas validation-only, so the direct boundary scanstill applies to marked models.
676 modules changed; beyond the marker and its import, the only differences
are the eight submitted-arm
validate_defaultremovals above. No class isrenamed or renumbered;
_generated.py,_ergonomic.py,error_details.pyanddocs/shared-type-names.mdare unchanged.Other workspaces
Nothing here touches reporting storage, ESA helpers,
operation_id, orformat_is_supported.🤖 Generated with Claude Code
Open workspace in Conductor