fix: resolve $ref and combinator keywords in JSON Schema to Python conversion - #1963
fix: resolve $ref and combinator keywords in JSON Schema to Python conversion#1963DerMayer1 wants to merge 2 commits into
Conversation
…nversion schema_type_to_python handled enum, const, type, arrays and objects, and returned Any for everything else. $ref, anyOf, oneOf and allOf all fell through, so a schema using them lost its constraints silently. Pydantic emits $ref for a nested model, so round-tripping a model through model_json_schema() produced a field typed Any. The three converters also read "properties" straight off the document, missing the top-level $ref Pydantic emits for a self-referential model — that produced a model with no fields at all. This reaches users through the Gemini backend, the one model that converts a JSON schema to a Python type (convert_to(..., ["dataclass", "typeddict", "pydantic"])) and sends it as response_schema. Other backends pass the schema through as str or dict and are unaffected. - resolve local $ref pointers (#, #/$defs/…, #/definitions/…) against the root schema, threading the root through the recursion - map anyOf/oneOf to Union; treat a one-element allOf as its subschema - break reference cycles with Any instead of recursing until the stack ends - leave remote and dangling refs, and multi-element allOf, as Any An items list (draft-7 tuple form) is now List[Any] rather than raising AttributeError.
ErenAta16
left a comment
There was a problem hiding this comment.
Ran this against a clone of main at be2cd15 and then against d986758, using the Person/Address case from the description plus a set of schemas I picked to probe the edges. It does what it says, and the headline case holds up end to end:
Person.address main: typing.Any this PR: Address(street: str, city: str)
Generation follows through, which is the part I cared about most:
{"name": "a", "address": {"street": "s", "city": "c"}} -> True
{"name": "a", "address": "not an object"} -> False
{"name": "a", "address": 5} -> False
One thing you should put in the description, because it makes the case stronger than what is written there now. On main this is not a silent loss of constraints for nested models, it is a hard failure. Any round-trips to a property with no type, and the regex builder refuses it:
to_regex(JsonSchema(json_schema_dict_to_pydantic(Person.model_json_schema(), "Person")))
ValueError: Unsupported JSON Schema structure {"title":"Address"}
So today the converter cannot round-trip any pydantic model that contains another model, it raises rather than generating something loose. "Silently loses its constraints" undersells it and might be why this has sat unreviewed.
Combinators, all Any on main:
anyOf [str, int] Union[str, int]
oneOf [str, null] Optional[str]
anyOf [int, null] Optional[int]
allOf single int
allOf multi Any
enum / array / plain unchanged
The recursion question was the one I expected to break, and it does not. Both a self-referential schema and a mutually recursive pair terminate:
Node value -> int, child -> Optional[Any]
A b -> Optional[AnonymousPydanticModel]
No RecursionError, and generation still works on the converted recursive model because the back edge lands as Optional[Any], which round-trips to {"anyOf": [{}, {"type": "null"}]} and the regex builder accepts that shape even though it rejects a bare untyped property. That is a nice accident of the two representations and I would not want it to stay accidental. There is no test covering a recursive schema, so if the cycle guard is ever tightened to return something other than Optional[Any], or loosened to plain Any, this goes straight back to the ValueError above and nothing in the suite catches it. Worth one test that converts a self-referential schema and calls to_regex on the result, not just on the annotations.
It is also worth a line in the docstring saying the back edge is intentionally unconstrained. It is the correct call, a finite regex cannot express an unbounded tree, but a reader hitting child -> Optional[Any] will otherwise read it as the bug this PR set out to fix rather than the designed stopping point.
Cross-PR note, since these will collide. #1949 changes field_definitions[property] = (Optional[typ], None) to (typ, None), and that exact line sits inside one of this PR's hunks as context, so merge order matters textually. Semantically they fit together rather than fight: this PR is what makes an explicitly nullable schema produce Optional, and #1949 stops a merely non-required field from producing it. Landing #1963 first and #1949 second reads cleanest, because after this PR the Optional that #1949 removes is genuinely redundant for the anyOf/oneOf null cases rather than being the only thing providing them. Flagging so they are not reviewed as independent.
One smaller thing, take it or leave it. Malformed subschemas are handled two different ways:
{"anyOf": [{"type": "integer"}, "junk"]} -> int (non-dict member dropped)
{"allOf": [{"type": "integer"}, {"type": "string"}]} -> Any (bails out)
Dropping the junk member is defensible, and so is bailing, but right now which one you get depends on the keyword. If that is deliberate a short comment saying so would help, since a schema with a typo'd member currently narrows to int without complaint.
Otherwise this is a solid change and the test additions cover the parts that matter.
Review of dottxt-ai#1963 pointed out that the recursion handling round-trips through to_regex by accident rather than by design, and that nothing in the suite pins it. Probing that turned up a case that is already broken today. The back edge of a cycle returned a bare Any. When the recursive property is optional, json_schema_dict_to_pydantic wraps it, so it serializes to {"anyOf": [{}, {"type": "null"}]} and the regex backend accepts it. When the property is required there is no wrapper, so it serializes to a property with no type ({"title": "Next"}) and to_regex raises ValueError -- the same error this PR set out to remove, just moved from nested models to recursive ones. A mutually recursive pair of required references fails the same way. The cycle now returns Optional[Any] directly, so termination no longer depends on the field happening to be optional. Null is also the only way an instance of a required self-reference can terminate, so it is the honest type rather than a workaround. Tests now call to_regex on the converted model instead of only asserting on annotations, which is what would have caught this: required back edge, optional back edge, a mutually recursive pair, and the nested-model case from the description. Reverting the guard fails three of them. Also documents two things the review asked for: a Notes section stating the back edge is deliberately unconstrained and why narrowing it breaks the regex backend, and a comment on the rule for malformed members. That rule is consistent across combinators -- anyOf, oneOf and allOf all drop a non-dict member and fall back to Any when none remain -- which is separate from a well-formed multi-member allOf being left as Any.
Review of dottxt-ai#1963 pointed out that the recursion handling round-trips through to_regex by accident rather than by design, and that nothing in the suite pins it. Probing that turned up a case that is already broken today. The back edge of a cycle returned a bare Any. When the recursive property is optional, json_schema_dict_to_pydantic wraps it, so it serializes to {"anyOf": [{}, {"type": "null"}]} and the regex backend accepts it. When the property is required there is no wrapper, so it serializes to a property with no type ({"title": "Next"}) and to_regex raises ValueError -- the same error this PR set out to remove, just moved from nested models to recursive ones. A mutually recursive pair of required references fails the same way. The cycle now returns Optional[Any] directly, so termination no longer depends on the field happening to be optional. Null is also the only way an instance of a required self-reference can terminate, so it is the honest type rather than a workaround. Tests now call to_regex on the converted model instead of only asserting on annotations, which is what would have caught this: required back edge, optional back edge, a mutually recursive pair, and the nested-model case from the description. Reverting the guard fails three of them. Also documents two things the review asked for: a Notes section stating the back edge is deliberately unconstrained and why narrowing it breaks the regex backend, and a comment on the rule for malformed members. That rule is consistent across combinators -- anyOf, oneOf and allOf all drop a non-dict member and fall back to Any when none remain -- which is separate from a well-formed multi-member allOf being left as Any.
|
Thanks for actually running it, and for the The back edge is safe only when the recursive property is optional, because then Same for a mutually recursive pair where both references are required. So the exact error this PR removes for nested models was still reachable through recursion. The cycle now returns Tests call On the description, you are right and I have updated it. Calling it a silent loss of constraints was wrong: On the malformed members, I looked into it and the behaviour is already consistent, though my examples in the code did not say so. All three combinators drop a non-dict member and fall back to The Agreed on #1949 and the ordering. This PR is what makes an explicitly nullable schema produce
|
3e1aaf3 to
0d43be2
Compare
ErenAta16
left a comment
There was a problem hiding this comment.
Reproduced your required-versus-optional split, and the asymmetry comes from two adjacent lines in json_schema_dict_to_pydantic:
if property not in required:
field_definitions[property] = (Optional[typ], None)
else:
field_definitions[property] = (typ, ...)With typ degraded to Any by the unresolved back edge, those two branches serialize very differently:
required (typ, ...) -> {"title": "Next"}
optional (Optional[typ], None) -> {"anyOf": [{}, {"type": "null"}], "default": null, "title": "Next"}
and that is the whole story:
required type=False anyOf=False -> no structural keyword to dispatch on
optional type=False anyOf=True -> dispatchable
The optional spelling smuggles in an anyOf that the container path is willing to walk, so the cycle appeared to work while carrying no type information at all. The required spelling has nothing to dispatch on and surfaces as the Unsupported JSON Schema structure you hit. So the recursion was not merely fragile against a future change, it was already broken for the more common way to write a linked structure, and the optional case was masking it.
The consequence for the tests is the part I would make sure lands. A recursive fixture written with an optional next passes on main today and would keep passing under a change that reintroduces this, because the anyOf wrapper does the dispatching rather than the fix. Any regression test for the cycle needs the required spelling, and ideally both, with a comment saying the optional one is a compatibility case rather than a guard. Otherwise the suite looks like it covers recursion and covers only the accident.
The mutually-recursive pair with both references required is the same shape one step out, and worth including for the reason you give: it is the case where neither side has a wrapper to hide behind.
Thanks for chasing it rather than taking the "it happens to work" answer. I would not have found the required case from where I was looking.
What
schema_type_to_pythonhandlesenum,const,type, arrays and objects. Everything else falls through toreturn Any.$ref,anyOf,oneOfandallOfare all unhandled, so a schema using them loses its constraints. For$refthat loss is not silent: it makes the converted model unusable.Pydantic emits
$reffor a nested model, so a round trip drops the nesting. This is not aquiet widening of the type: the converted model is then unusable, because the regex backend
refuses a property with no
type.So on
mainthe converter cannot round-trip any pydantic model that contains another model.The three converters also read
propertiesdirectly off the document. Pydantic emits a top-level$reffor a self-referential model —{"$defs": {...}, "$ref": "#/$defs/Node"}— sopropertiesis empty and the generated model has no fields at all:Why it's user-visible
Gemini is the one backend that converts a JSON schema into a Python type and ships it as
response_schema—models/gemini.py:181,JsonSchema.convert_to(output_type, ["dataclass", "typeddict", "pydantic"]). So passing a schema with a nested object to Gemini sends a constraint that doesn't contain the nested structure, with no warning.Other backends are unaffected: OpenAI, Ollama and LMStudio convert to
dict, dottxt tostr, and the local backends compile the raw schema.Scope
In. Local
$ref(#,#/$defs/…,#/definitions/…),anyOf/oneOf→Union, and a one-elementallOf→ its subschema (the form Pydantic emits for a$refwith sibling metadata).Out. Remote refs, dangling pointers and multi-element
allOfstayAnyrather than raising, so an unsupported schema still degrades instead of breaking. Merging two or moreallOfsubschemas means reconciling conflicting keywords and competingrequiredsets; that's a separate change.Reference cycles resolve to
Optional[Any]. A recursive schema has no finite Python typehere, and the alternative is recursing until the stack ends. It is
Optional[Any]ratherthan a bare
Anyso the back edge round-trips to{"anyOf": [{}, {"type": "null"}]}, whichthe regex backend accepts; a bare
Anyserializes to a property with notypeand raisesthe same
ValueErroras above when the recursive property is required. Null is also theonly way an instance of a required self-reference can terminate.
One incidental change:
itemsgiven as a list (the draft-7 tuple form) previously raisedAttributeErrorfromschema.geton a list. It is nowList[Any]. Happy to drop that if you'd rather keep the PR to the$refpath.Why this is a fix rather than a feature
tests/types/test_json_schema_utils.pyalready covers nested objects and asserts full recursion into them (test_json_schema_dict_to_pydantic_nested_objectand the typeddict/dataclass equivalents).$refis the same intent expressed through$defsrather than inline, so the existing tests describe behaviour that$refschemas were silently not getting.Tests
17 added, covering local
$ref, the draft-07definitionskeyword, unresolvable refs, root-level$ref, recursive$ref,anyOf/oneOf/allOf, the typeddict and dataclass paths, the end-to-end Pydantic round trip, andto_regexoverconverted recursive and nested models rather than their annotations alone.
tests/types/: 302 passed, 2 failed. Both failures aretest_dsl.py::test_dsl_cfg_from_fileandtest_dsl_json_schema_from_file, which fail identically on a clean checkout ofmainon Windows (NamedTemporaryFilecannot be reopened while held). Unrelated to this change.ruff 0.9.1 with
--config=pyproject.tomland mypy 1.14.1 with--allow-redefinitionare both clean on the changed files.