Skip to content

[BUG](codegen) Skip RootModel entry points from generation and document them as aliases - #594

Open
Seth Fitzsimmons (sethfitz) wants to merge 6 commits into
mainfrom
rootmodel-entry-points
Open

[BUG](codegen) Skip RootModel entry points from generation and document them as aliases#594
Seth Fitzsimmons (sethfitz) wants to merge 6 commits into
mainfrom
rootmodel-entry-points

Conversation

@sethfitz

@sethfitz Seth Fitzsimmons (sethfitz) commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #593. Follow-up to #590, which unwrapped RootModel-typed fields to their bare root shape. This handles a RootModel registered as a top-level entry point, which was still extracted as a RecordSpec -- producing a spurious root column in the generated Spark schema -- and was omitted from the markdown reference.

Changes

Expression generation skips a RootModel entry point silently (the same as would occur for NewTypes or other entry point contributions) rather than emitting a wrong schema. An entry point is a contribution mechanism, not only a generation one -- the type still resolves to its bare shape wherever it is used as a field (the #590 path).

Markdown generation documents a RootModel entry point as a type alias over its bare root value, reusing the NewType page since both are named aliases over an underlying shape. The split lives at the discovery bridge: extract_model_spec yields the feature/union specs both pipelines generate from (a RootModel returns None), and extract_alias_spec turns a RootModel into a NewTypeSpec. The markdown pipeline takes these aliases alongside the supplementary types it collects from field trees -- a RootModel is reachable no other way, since a RootModel field unwraps to its bare shape and names no type.

A self-referential RootModel (RootModel[list["Self"]]) now raises a clear TypeError instead of recursing forever into an opaque RecursionError. Unwrapping a RootModel erases its identity, so -- unlike a self-referential BaseModel, which the resolver terminates with a starts_cycle back-edge -- a self-referential root has no node to carry a back-edge and no finite bare-shape form. A seen_rootmodels frozenset threaded through _unwrap detects re-entry at the back-edge.

Dependency: pydantic >= 2.13

Documenting the alias reads the RootModel's root description from model_fields["root"].description. Pydantic surfaces it there only from 2.13.0: pydantic#13129 ("Preserve RootModel core metadata") carries the root field's FieldInfo metadata -- description included -- through to the core schema. On 2.12.0 the description stays nested in the root type's Annotated metadata and model_fields["root"].description is None, so the lowest-direct CI cell -- which resolves every direct dependency to its declared floor -- failed test_root_field_description_used_without_docstring.

This raises the pydantic floor from >=2.12.0 to >=2.13.0 across the workspace packages (with a matching uv.lock specifier bump). Verified under the failing configuration: an isolated UV_RESOLUTION=lowest-direct install resolves pydantic to 2.13.0 and the root-description test passes.

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

🗺️ Schema reference docs preview is live!

🌍 Preview https://staging.overturemaps.org/schema/pr/594/schema/index.html
🕐 Updated Jul 23, 2026 19:56 UTC
📝 Commit 22f605d
🔧 env SCHEMA_PREVIEW true

Note

♻️ This preview updates automatically with each push to this PR.

@sethfitz

Seth Fitzsimmons (sethfitz) commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

documents a RootModel entry point as a type alias over its bare root value, reusing the NewType page since both are named aliases over an underlying shape

Roel Bollens (@RoelBollens-TomTom) Victor Schappert (@vcschapp) this raises an interesting question (since I hadn't understood how RootModel worked before); should we be using RootModel subclasses in place of NewTypes? I'm exploring that a bit, but wanted to flag it for you to think about as well. There might be benefits, particularly around the union handling (and the docstring workarounds, which now look like they were a smell).

A RootModel discovered as an entry point has no record structure of its
own -- it serializes as its bare root value -- so extracting it as a
top-level RecordSpec produced a spurious `root` column. Entry points are
a contribution mechanism, not only a generation one: an extension may
register a RootModel as a type used as a field elsewhere. So
extract_model_spec now skips a RootModel entry rather than emitting a
wrong schema, and the type still resolves to its bare shape wherever it
is used as a field.

Relates to #583.

Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
A RootModel entry point drops out of expression generation -- it has no
record structure -- but not markdown: it is a named alias over its bare
root value, like a NewType, and readers still need it documented.

The split lives at the discovery bridge. extract_model_spec yields the
feature/union specs both pipelines generate from (a RootModel returns
None). extract_alias_spec turns a RootModel into a NewTypeSpec, reusing
the NewType page since both are named aliases over an underlying shape.
The markdown pipeline takes these aliases alongside the supplementary
types it collects from field trees -- a RootModel is reachable no other
way, since a RootModel field unwraps to its bare shape and names no type
-- and places and cross-references them identically. Like every other
non-feature type, a RootModel is simply absent from expression
generation.

Relates to #583.

Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
- spec_discovery: compute `partitions` after the RootModel early return,
  so the skipped path no longer builds a value it discards.
- newtype_extraction: document why `extract_rootmodel_alias` needs no
  `is_custom_docstring` guard (a RootModel subclass without a docstring
  has `__doc__ = None` -- no inherited base docstring to filter).
- type_analyzer: state the self-referential-RootModel precondition at
  the unwrap branch -- it bypasses the resolver's cycle detection,
  unguarded by the same convention `extract_model` states, since no
  schema defines a RootModel.
- cli/pipeline: rename `alias_specs` -> `external_specs` at the markdown
  supplement-injection point; the parameter is typed `SupplementarySpec`,
  the open-ended door for externally-supplied supplements (extension
  contributions), not aliases specifically. Also drop its speculative
  default on `_generate_markdown` -- its one real caller always passes it.

Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
…mitation

The note first appealed to a convention `extract_model` does not state,
then to "no Overture schema defines one" -- but this codegen is built
for use beyond Overture, where a downstream RootModel could be
self-referential. State it as a known limitation instead: guarding would
thread cycle state through the otherwise-stateless unwrap, deferred
until a use case needs self-referential roots.

Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
Unwrapping a RootModel erases its identity, so -- unlike a
self-referential BaseModel, which the resolver terminates with a
`starts_cycle` back-edge -- a self-referential root
(`RootModel[list["Self"]]`) has no node to carry a back-edge and no
finite bare-shape form. The unwrap recursed into it forever, surfacing
as an opaque RecursionError.

Thread a `seen_rootmodels` frozenset through `_unwrap` (defaulted on the
`_recurse` closure, so ordinary descents carry it unchanged and only the
RootModel branch augments it) and raise a clear TypeError on re-entry --
the analyzer's counterpart to the model layer's
`ancestors`/`starts_cycle` detection, deterministic and catching the
cause at the back-edge.

The test rewires the self-reference at runtime: a statically recursive
generic base crashes mypy 2.3.0 (an internal-error bug), so the base
stays non-recursive and `model_fields["root"].annotation` is pointed
back at the model after definition.

Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
`extract_rootmodel_alias` reads a RootModel's root description from
`model_fields["root"].description`. Pydantic hoists that description from
the root type's `Annotated` metadata onto the root `FieldInfo` only from
2.13.0 -- on 2.12.x it stays nested in `root.annotation` and
`.description` is None. The `lowest-direct` CI job resolves the declared
floor, so it installed 2.12.0 and failed
`test_root_field_description_used_without_docstring`.

Raise the floor to the first version that surfaces the description.

Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
@RoelBollens-TomTom

Copy link
Copy Markdown
Collaborator

documents a RootModel entry point as a type alias over its bare root value, reusing the NewType page since both are named aliases over an underlying shape

Roel Bollens (Roel Bollens (@RoelBollens-TomTom)) Victor Schappert (Victor Schappert (@vcschapp)) this raises an interesting question (since I hadn't understood how RootModel worked before); should we be using RootModel subclasses in place of NewTypes? I'm exploring that a bit, but wanted to flag it for you to think about as well. There might be benefits, particularly around the union handling (and the docstring workarounds, which now look like they were a smell).

I'd say probably not, but still worth discussing. When I initially saw the NewTypes (and type alias for segment, and this is before the markdown and pyspark codegen) I did wonder why RootModel wasn't used, but when internalising this, I don't think you would actually have gained anything, and in fact would have added some clunkiness (you'd need to to through the .root to get and set values, unless having dunder methods on the RootModel class). It does make the discovery and extensions mechanism messier but I think thats a fine trade off. The union handling I would need to think about whether that would help, but the docstring workarounds, if I assume correctly what you meant here, should have been done through a Field metadata annotation imho, as that is what Pydantic handles for JSON schema just fine.

@sethfitz

Copy link
Copy Markdown
Collaborator Author

I'll be working through it for a bit longer, but here's where I think I'm landing: migrate Segment to a RootModel subclass, which preserves the discovery contract (because the union typedef will then migrate to BaseModel), migrate the collection containers (I don't have a clear justification, but this is what my gut says), and keep the scalar (numeric, semantic) NewTypes.

It seems like it makes the implementation of discovery and extension slightly messier, but it cleans up the interfaces a bit and allows things like Segment's docstring to actually propagate correctly (I hadn't noticed that it was dropped before; this is a different workaround from what I was referring to, which was attaching them to the scalar NewTypes in a way that also makes Python introspection work properly, since descriptions don't surface as docstrings). I think I tried to get a Field annotation to carry the description, but it was sitting at the wrong level, but that was before reworking the type analysis. I'll double check that as part of this spike.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RootModel entry points produce a spurious root column and go undocumented

2 participants