[FEATURE](codegen) Carry field defaults through the IR into the Markdown reference, and refuse default_factory - #759
Open
Seth Fitzsimmons (sethfitz) wants to merge 4 commits into
Open
Seth Fitzsimmons (sethfitz) wants to merge 4 commits into
Seth Fitzsimmons (sethfitz) wants to merge 4 commits into
Conversation
Extraction read FieldInfo for description and requiredness but dropped the declared default, so anything needing to know a field declares one -- a renderer, or a check enforcing #695's policy -- had to re-walk Pydantic and redo the unwrapping extraction already does. FieldSpec now carries `default`, and specs.py exports UNDEFINED for the absent case. UNDEFINED is Pydantic's own PydanticUndefined re-exported under a shorter name rather than a second sentinel: a consumer reads `spec.default is UNDEFINED` without reaching past the IR into pydantic_core to ask the IR a question, the two values meaning "no default" cannot drift apart, and copy.deepcopy and pickle both return the same object, where a freshly minted singleton comes back as a different one. A declared default of None stays None. It is legal, and collapsing it into the absent case would leave a consumer unable to tell the two apart. `= None` is also how a Pydantic field is declared optional; the IR reports what it finds and leaves that judgement to the consumer, because nothing in the source distinguishes the two. Omitable[T] is Field(default=MISSING) -- machinery for JSON Schema omissibility, not a value anyone declared -- so MISSING normalizes to UNDEFINED. Feature.bbox and Feature.id are both Omitable and every feature model inherits them, so without the normalization every feature type would claim two defaults it does not have. `default` is optional on FieldSpec, so no construction site changes. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
A field declaring `default_factory` had three possible fates and the IR took the worst one silently. Invoking the factory freezes one sample of a value meant to be produced per instance, and the frozen sample is indistinguishable from a declared literal. Recording it as "no default" -- what the carrier did until now -- hides a declared default from every consumer, including any check looking for declared defaults. #695 forbids default_factory outright for the same reason a callable cannot be rendered into Markdown, a PySpark expression or JSON Schema. Refusing is the only one of the three the author can see. So extraction now raises, naming the model and field. This is #640's shape applied to one more kwarg: decide support-or-reject, and reject legibly rather than dropping in silence. Nothing in the schema declares a factory today, so this refuses nothing that exists. The extraction tests that used `Field(default_factory=list)` did so incidentally, to give a recursive forward-ref model an empty list; both drop the default entirely, since what they test is forward-ref resolution. `_is_field_required` no longer consults default_factory: the refusal runs first in the same loop, so a factory cannot reach it. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
🗺️ Schema reference docs preview is live!
Note ♻️ This preview updates automatically with each push to this PR. |
Seth Fitzsimmons (sethfitz)
requested review from
Roel Bollens (RoelBollens-TomTom) and
Victor Schappert (vcschapp)
September 22, 2026 23:38
FieldSpec carries each field's declared default, but the Markdown renderer never read it, so a model declaring `level: int = 0` documented the field as optional without saying what an absent value means. A non-null default now renders as an italic `Default:` note in the field's description cell, ahead of any constraint notes, on every row of the field table -- including the dot-notation rows expanded inline from a sub-model, where a reader who never opens the sub-model's page still needs it. A default of None gets no note. `= None` is how a Pydantic field is declared optional, and the `(optional)` qualifier in the type column already says so -- a note would repeat it on every optional row. Enum members show their value, the form a record carries, and an empty string shows as `""` rather than the blank cell an example table uses for it. The note helper is renamed `_annotate_notes`, since a default is not a constraint. The published reference does not change: #695 forbids non-null defaults, and every default the schema declares is None. Generating all 139 pages before and after this commit produces identical output. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
This branch was successfully 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.
Description
Codegen renderers can read each field's declared default: a target that supports defaults can emit one, and a target that does not can report that it dropped one. The Markdown reference is the first to emit one, as a note on the field's row. A model that declares
default_factoryfails extraction with an error naming the field, where before the factory was discarded silently.FieldSpec, the codegen IR's per-field record, is(name, shape, description, is_required, is_optional). Extraction readsFieldInfo.defaultandFieldInfo.default_factory, uses them to settle one question — whether the field is required — and discards the values. Nothing downstream can emit a field's default, report that it had to drop one, or refuse one.This carries the value, renders it in Markdown, and refuses the one form of it no target can render.
FieldSpec.default, withUNDEFINEDexported fromextraction.specsfor the absent case.UNDEFINEDis Pydantic's ownPydanticUndefinedunder a shorter name, not a second sentinel: a consumer readsspec.default is UNDEFINEDwithout importingpydantic_coreto ask the IR a question, the two values meaning "no default" cannot drift apart, andcopy.deepcopyandpickleboth return the same object, where a freshly minted singleton returns a different one.A declared default of
Noneis kept asNone, distinct from absence:Noneis legal, and collapsing the two leaves a consumer unable to tell them apart.= Noneis also how a Pydantic field is declared optional, and nothing in the source separates that from an intended null default, so the IR reports what it finds. Pydantic's ownmodel_json_schema()does the same, emitting"default": null.Omitable[T]installsField(default=MISSING)to get JSON Schema omissibility instead of Pydantic nullability.MISSINGis machinery for "this key may be absent", not a value anyone declared, so it normalizes toUNDEFINED— andFeature.bboxandFeature.idare bothOmitable, inherited by every feature model, so without that normalization every feature type in the schema would claim two defaults it does not have.default_factoryis refused at extraction, naming the model and field. #695 forbids it outright, because a callable cannot be rendered into any representation but Python. The other two things the extractor could do are both silent: invoking the factory freezes one sample of a value meant to be produced per instance, and the frozen sample is indistinguishable from a declared literal; recording it as "no default" hides a declared default from every consumer. Refusing is the only option the author ever sees.The Markdown reference shows a non-null default as an italic
Default:note in the field's description cell, ahead of any constraint notes. It goes on every row, including the dot-notation rows expanded inline from a sub-model, so a reader who never opens the sub-model's page still sees it. A default ofNonegets no note: that is the optional spelling above, and the(optional)qualifier in the type column already says it. An enum member shows its value, the form a record carries.No field in the schema declares a factory, so nothing that exists today is refused. The published reference does not change either: every declared default in the schema is
= Noneon an optional field, and #695 forbids any other. All 139 generated pages are identical before and after this branch.This does not warn or fail on a declared default. Enforcing #695's policy belongs downstream, in a schema validator distinct from a data validator, and carrying the value is what makes it possible.
Reference
default_factoryoutright. This carries the value so a target can see it; it does not reintroduce defaults to the schema.Field()kwargs. [ENHANCEMENT](schema) Adopt a policy: schema fields carry no non-null default #695 settleddefaultas policy; this settles it in the extractor, and settlesdefault_factoryalongside it.stac-table-columnsrenderer, which readsFieldSpec.defaultto report defaults it cannot carry.is_deprecatedto the same record.Checklist
Documentation website
Docs preview for this PR.