Skip to content

fix: name root self-references after the generated root class - #5671

Merged
zzstoatzz merged 3 commits into
PrefectHQ:mainfrom
BlueX888:fix/prep-root-selfref-forwardref-name
Nov 2, 2026
Merged

zzstoatzz merged 3 commits into
PrefectHQ:mainfrom
BlueX888:fix/prep-root-selfref-forwardref-name

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Description

json_schema_to_type returned a broken type for any recursive object schema that carries a root title: validating the result with TypeAdapter raised PydanticUserError: ... is not fully defined. Untitled recursive schemas already worked; the titled form — the one most schema producers emit — did not.

Why: a {"$ref": "#"} reference resolves to the root schema, whose generated class is named after the root title (falling back to Root). The ref == "#" branch in _convert_schema (fastmcp_slim/fastmcp/utilities/json_schema_type.py:766 on main) built the forward reference from the ref node instead. A bare {"$ref": "#"} node has no title, so the forward reference was always named Root while the class was named e.g. Node, and the reference never resolved. The mismatch hit every generic reference position (anyOf, array items) and all fields of schemas that build a Pydantic model (properties + additionalProperties: true), which go through the generic handling rather than _build_dataclass's direct self-reference check.

Changes:

  • _Conversion now tracks root_class_name, the sanitized name of the class generated for the root schema — the explicit name override, else the root title, else Root — the same derivation _create_dataclass/_create_pydantic_model already use.
  • The ref == "#" branch returns ForwardRef(root_class_name), so every reference position targets the class that will actually be generated. This also fixes the case where an explicit name= override makes the class name differ from the root title.
  • Regression tests in TestCircularReferences cover a titled root self-referenced via anyOf, via array items, via a direct property on a schema with additionalProperties: true (the Pydantic-model branch), and with an explicit name= override.
  • Review follow-ups (953953f): the object member of a root type list is converted from the root schema itself, so ["object", "null"] roots keep the root identity and name that "#" references resolve to; a non-string root title is ignored instead of crashing the conversion; and _build_dataclass's direct "#" shortcuts route through root_class_name, so a direct root reference on a nested class no longer points at the nested class itself.

Testing:

  • New tests fail on main with PydanticUserError: ... is not fully defined and pass with this change:
    • uv run pytest tests/utilities/json_schema_type/test_advanced.py -k self_reference → 6 passed, 72 deselected
  • Existing suites, no regressions:
    • uv run pytest tests/utilities/json_schema_type/ → 419 passed, 1 skipped
    • uv run pytest tests/utilities/ tests/client/test_elicitation.py tests/client/test_elicitation_enums.py → 1313 passed, 1 skipped (elicitation builds its response type through json_schema_to_type_adapter)
  • Static checks: uv run prek run --all-files → validate-pyproject, prettier, ruff check, ruff format, ty check, gitleaks, codespell all Passed (loq reports pre-existing, unenforced repo-wide size findings).

Fixes #5669

Contribution type

  • Bug fix (simple, well-scoped fix for a clearly broken behavior)

Checklist

  • This PR addresses an existing issue (or fixes a self-evident bug)
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

@github-actions

This comment has been minimized.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T02:18:26.362024Z 6a2dee5 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@marvin-context-protocol marvin-context-protocol Bot added the bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. label Oct 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a2dee5658

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# Handle self-reference
if ref == "#":
return ForwardRef(_sanitize_name(schema.get("title", "Root")))
return ForwardRef(_conversion.get().root_class_name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disambiguate root refs from identically named nested classes

When a nested object has the same sanitized title as the root (or as the explicit name= override), this ForwardRef resolves in the nested class's namespace to the nested class rather than the document root. For a "$ref": "#" reached through anyOf, items, or the Pydantic-model path, validation then enforces the nested object's fields, so it can accept inputs that violate the root schema and reject valid root instances. Use an unambiguous root identity (or unique generated names) instead of the potentially duplicated class name.

Useful? React with 👍 / 👎.

_Conversion(
root_hash=root_hash,
root_size=root_size,
root_class_name=_sanitize_name(name or schema.get("title", "Root")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the root class name in nested cache keys

When the same schema is converted with two different name= overrides and a nested generated object contains a generic root reference, this value changes but the nested class cache key does not: _create_dataclass and _create_pydantic_model key it by the schema/root hashes and its own name, while the schema-derived root hash omits the override. The second conversion therefore reuses (for example) a cached ForwardRef('First') while building Second, leaving TypeAdapter(Second) not fully defined or binding nested values to the first class if that name is available. Include root_class_name in generated-class cache keys.

Useful? React with 👍 / 👎.

Rename a nested class that would share the root's generated name, so
pydantic binds "$ref": "#" to the root class instead of capturing it
in the nested class's own name. Also include the root class name in
generated-class cache keys, so converting the same schema under two
different name= overrides no longer reuses a nested class whose
forward references name the first conversion's root.
@BlueX888

Copy link
Copy Markdown
Contributor Author

Both findings confirmed and fixed in b1d79f5.

P1 (same-name binding): confirmed — pydantic binds a class's own name to that class when resolving its annotations, so a nested class sharing the root's name captured every "$ref": "#" reference. Fixed by renaming colliding nested classes (Node_2), matching the file's existing dedup convention. Covered by test_nested_class_named_like_root_does_not_shadow_root.

P2 (cache pollution): confirmed — nested classes bake the root class name into their forward references, but the cache key omitted it. The root class name is now part of generated-class cache keys. Covered by test_name_override_does_not_reuse_stale_nested_class.

@BrickerP BrickerP left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the diff at b1d79f5 against main and ran the module on its own (pydantic 2.x) with some probe schemas, comparing main against this head.

What's sound. Tracking root_class_name on _Conversion and returning ForwardRef(root_class_name) from the ref == "#" branch of _convert_schema fixes the bug at its source. The old code named the ref after the bare {"$ref": "#"} node, which never has a title, so the ref could not resolve. Building the name as name or title or "Root" matches how _create_dataclass/_create_pydantic_model name the root, so the name= override case works too. The second commit is also right. Pydantic binds a class's own name before the root, so a nested class with the root's name (_nested_class_name → Node_2) would capture # refs, and renaming it stops that. Adding root_class_name to the _classes cache key stops a First forward ref from being reused while building Second. I confirmed that these now work where they failed on main: $defs recursion back to the root (Tree → Branch → #), root "my node" with nested "my-node" (both sanitize to my_node), and an untitled root with an untitled nested object that refs #. That last one used to validate # against the nested shape; now it validates against the root shape.

Residuals (the first two are new in this PR):

  • Regression: untitled type: ["object", "null"] root. {"type": ["object","null"], "properties": {"name": {...}, "next": {"anyOf": [{"$ref": "#"}, {"type": "null"}]}}} validates on main (class Root). On this branch TypeAdapter raises "not fully defined" on Optional[Root_2]. The cause is the type-list branch of _convert_schema, which builds the object class from type_schema = dict(schema). That copy fails the schema is not schemas identity check in _nested_class_name, so the root class itself gets renamed Root_2 while # still points to Root. The titled form was already broken on main and still is (Node_2). An explicit root flag, or treating a copy that differs only in type as the root, would fix both.
  • Regression: non-string title now crashes every conversion. _convert_normalized now calls _sanitize_name(name or schema.get("title", "Root")) up front for every schema, including scalar and dict-only roots that never create a class. {"type": "string", "title": null}, {"type": "string", "title": 5} and {"type": "object", "title": 5} all converted on main. Here they raise TypeError: expected string or bytes-like object from re.sub. These schemas are malformed, but they can arrive from untrusted servers. Computing the name lazily, or only using title when isinstance(title, str), avoids the crash.
  • Pre-existing, same bug class, not covered: the shortcut in _build_dataclass (elif prop_schema.get("$ref") == "#": field_type = ForwardRef(sanitized_name)) uses the current class's name. On the root that matches, but a nested dataclass with a direct "root": {"$ref": "#"} property points the ref at itself. With root Doc (required value) and nested Child (required tag), {"child": {"root": {"tag": "x"}}} is accepted and a valid root shape is rejected. Using _conversion.get().root_class_name there fixed both cases for me. The ref == "#" early return at the top of _build_dataclass probably needs the same change.
  • Pre-existing: an untitled root with properties but no type goes through _create_dataclass(schema, schema.get("title", "<unknown>"), ...). The class is named field_unknown while # points to Root, so it's still "not fully defined". The titled typeless form is fixed by this PR.
  • CI: static_analysis is red because ty check flags the new tests (test_advanced.py:290, :291, :349, :355): the child.__args__[0] if hasattr(child, "__args__") else child unwrap on the result of get_dataclass_field(...).type. A small typed helper or a cast would make it pass.
  • Nit: the _2 suffix isn't checked against other names. A nested object really titled Node_2 next to a renamed one gives two classes with the same __name__. That's harmless for # resolution but confusing in error messages and generated schemas.

The approach is right. I'd fix the first two regressions and the ty failure before merge, and ideally route the _build_dataclass direct-property shortcut through root_class_name in the same PR, since it's the same bug.

- convert the root schema itself (not a copy) for the object member of
  a root type list, so the generated class keeps the name that
  "$ref": "#" resolves to; untitled ["object", "null"] roots no
  longer build Root_2 against refs to Root, and the titled form now
  resolves where main also failed
- only treat a string title as a class-name source in
  _convert_normalized, so malformed titles (null, numbers) no longer
  crash conversions that previously succeeded
- route _build_dataclass's direct "#" shortcuts through root_class_name
  so a direct root reference on a nested class no longer points at the
  nested class itself
- add an unwrap_optional test helper so ty check passes on the tests
@BlueX888

Copy link
Copy Markdown
Contributor Author

Thanks @BrickerP — everything you flagged is addressed in 953953f, including the same-bug _build_dataclass case you suggested routing in this PR:

  • Untitled type: ["object", "null"] root: the type-list branch now converts the root schema itself for the object member instead of a copy, so the class keeps the root identity _nested_class_name checks for. The untitled form restores main's behavior, and the titled form now resolves too (main produced Node_2 there).
  • Non-string title: _convert_normalized only treats a string title as a class-name source (empty string still counts, matching the class-creation paths), so {"type": "string", "title": null}, {"type": "string", "title": 5} and {"type": "object", "title": 5} convert again. Added your three probes as parametrized tests.
  • _build_dataclass direct # shortcuts: both the early return and the property shortcut now use _conversion.get().root_class_name. Added your Doc/Child case: a root-shaped root value validates and a Child-shaped one is rejected.
  • static_analysis: the ty check errors came from the __args__ unwraps in the new tests; replaced them with a small typed helper (unwrap_optional). uv run prek run --all-files passes.

The two pre-existing items (untitled root with properties but no type; the _2 suffix not colliding with a genuinely titled Node_2) are left out to keep this PR scoped — happy to follow up separately.

uv run pytest tests/utilities/ tests/client/test_elicitation.py tests/client/test_elicitation_enums.py → 1313 passed, 1 skipped.

@BlueX888

Copy link
Copy Markdown
Contributor Author

@zzstoatzz — quick coordination question on this one.

#5779 now carries this PR's root-reference binding and tests, with BlueX888 credited as co-author. Would you prefer that I close #5671 in #5779's favor, or keep the focused fix here and land it first? Happy to close it once you confirm.

Two facts that may matter for sequencing:

Also, #5779's body says Closes #5655; the issue tracked here is #5669 (assigned to me), which stays open unless it is linked from whichever PR lands.

@BrickerP BrickerP left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE

Re-reviewed at 953953f. The follow-up commit clears the residuals I flagged at b1d79f5:

  • type-list roots now convert the root schema itself for the object member, so untitled ["object","null"] keeps the name "$ref": "#" resolves to (and the titled form that was broken on main now works too)
  • non-string title values are ignored for class naming instead of crashing in _sanitize_name
  • _build_dataclass's direct "#" shortcuts go through root_class_name
  • unwrap_optional unblocks ty check on the new tests

The _2 suffix collision and typeless untitled roots remain edge cases outside this fix's scope. Good to merge from my side.

zzstoatzz added a commit that referenced this pull request Nov 2, 2026
@zzstoatzz
zzstoatzz merged commit 2c3a2de into PrefectHQ:main Nov 2, 2026
24 of 25 checks passed
zzstoatzz pushed a commit that referenced this pull request Nov 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

json_schema_to_type fails on recursive schemas with a root title: "$ref": "#" never resolves

3 participants