fix(types): preserve anyOf and oneOf constraints - #1951
Conversation
|
📚 Documentation preview: https://dottxt-ai.github.io/outlines/pr-preview/pr-1951/ Preview updates automatically with each commit. |
| members = tuple( | ||
| schema_type_to_python(member, caller_target_type) | ||
| for member in schema[keyword] | ||
| ) |
There was a problem hiding this comment.
The early return drops sibling constraints, but JSON Schema combines them with anyOf. For {"type":"string","anyOf":[{"const":"ok"},{"const":1}]}, this produces Literal["ok", 1] and Pydantic accepts 1, even though the schema rejects it. Can the outer constraints be preserved here?
|
Good catch, and you're right that the sibling schema_type_to_python({"type": "string", "anyOf": [{"const": "ok"}, {"const": 1}]}, "pydantic")
# -> Union[Literal["ok"], Literal[1]] # the outer "type": "string" is lost, so 1 slips throughThe reason I kept the early return is that combining This PR is scoped to #1950, which is specifically about Happy to open a follow-up issue documenting the intersection gap so it's tracked rather than silently lost. Would you prefer I add a short comment in the code flagging that sibling scalar constraints alongside |
| # widening the value back to its bare type. | ||
| return Literal[schema["const"]] | ||
|
|
||
| for keyword in ("anyOf", "oneOf"): |
There was a problem hiding this comment.
this returns before type is consulted, so the constraint-only form of anyOf/oneOf loses the declared type: {"type": "string", "anyOf": [{"minLength": 1}, {"const": "x"}]} has members with no type, each maps to Any, and Union[Any, Any] collapses to plain Any so the field stops being a str. Same widening the const comment above is guarding against. Would it be safer to only take this branch when type is absent, or to intersect with the outer type the way the isinstance(t, list) branch reuses {**schema, ...}?
There was a problem hiding this comment.
You were right that the early union return widened sibling type constraints. I pushed 692f64e: anyOf/oneOf are converted to a Python union only when no outer type is present; otherwise the existing outer-type mapping remains the conservative approximation of the JSON Schema intersection. Added regressions for untyped branches and a conflicting const. The type-utils module is 25/25 green, and the repo-pinned pre-commit/mypy/ruff hooks pass.
Signed-off-by: Solaris-star <820622658@qq.com>
ErenAta16
left a comment
There was a problem hiding this comment.
The core of this works. Ran it against a clone of main at be2cd15 and then against 692f64e:
main this PR
anyOf [str, int] Any Union[str, int]
oneOf [str, null] Any Optional[str]
anyOf [int, null] Any Optional[int]
nested anyOf Any int
plain type / enum / array unchanged
Guarding on "type" not in schema is the right precedence choice, since a schema carrying both is already ambiguous and deferring to type keeps the existing path intact.
There is a regression I would want fixed before this lands, though. The members go straight into the recursive call with no guard on what they are, and JSON Schema allows a subschema to be a boolean, not just an object. Draft 6 onward defines true as the always-valid schema and false as the never-valid one, so {"anyOf": [{"type": "integer"}, true]} is a legal document. Today it degrades to Any; on this branch it raises:
schema_type_to_python({"anyOf": [{"type": "integer"}, True]}, "pydantic")
# main: typing.Any
# this PR: TypeError: argument of type 'bool' is not iterable
It comes through the realistic entry point too, not just the helper:
json_schema_dict_to_pydantic({
"type": "object",
"properties": {"x": {"anyOf": [{"type": "integer"}, True]}},
"required": [],
})
# main: x -> Optional[Any]
# this PR: TypeError: argument of type 'bool' is not iterableA non-dict member reaches "enum" in schema and schema.get(...) inside the recursive call, so a string member fails a bit differently but just as hard (AttributeError: 'str' object has no attribute 'get'). Turning a permissive fallback into an exception is the part that concerns me more than the specific input, because callers converting third-party schemas currently get a loose model and would now get a traceback.
Filtering the members to dicts before the comprehension is enough, and it also keeps the Any fallback meaningful when everything is filtered out.
The other thing worth raising is overlap. #1963 covers the same two keywords and I ran both branches through the same matrix:
this PR #1963
anyOf [str, int] Union[str, int] Union[str, int]
oneOf [str, null] Optional[str] Optional[str]
anyOf [int, null] Optional[int] Optional[int]
allOf single Any int
$ref to nested model Any resolved model
anyOf with boolean TypeError int
On anyOf and oneOf the two are behaviourally identical, so this is a subset of #1963 rather than a complementary change, and #1963 already filters non-dict members so it does not have the crash above. They also touch the same function in the same file, so they will conflict.
I do not think that makes this PR redundant on the process side, it is the smaller and easier change to review, and if #1963 stalls this is the one that should go in. But the two should be decided together rather than reviewed as independent contributions. Flagging for the maintainers.
Tests are fine as far as they go. If this stays separate, a case with a boolean subschema member would pin the behaviour the fix above restores.
ErenAta16
left a comment
There was a problem hiding this comment.
Confirming this fix and adding the round-trip case, since it's the one that makes the priority clear.
Pydantic emits anyOf for Optional[T] and Union[...], so this isn't only a hand-written-schema concern — any model going through json_schema_dict_to_pydantic loses those fields:
class Source(BaseModel):
maybe_int: Optional[int]
either: Union[int, str]
plain: int| field | on main |
with this PR |
|---|---|---|
maybe_int |
Any |
Optional[int] |
either |
Any |
Union[int, str] |
plain |
int |
int |
On main the regenerated schema for maybe_int is just {"title": "Maybe Int"} — nothing left to constrain generation with. With the fix it round-trips back to {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Maybe Int"}, identical to what Pydantic produced.
The "type" not in schema guard is the right call. I had written the same fix without it (#1973, now closed in favour of this one) and it diverges on {"type": "object", "anyOf": [...]}: without the guard the declared type is dropped in favour of the branches, which is a widening. JSON Schema treats both as constraints that apply together, so letting type win is the conservative reading.
{"type": "object", "anyOf": [{"type":"integer"},{"type":"string"}]}
without the guard -> Union[int, str] (type discarded)
this PR -> falls through to the type dispatch
Empty branch lists agree either way: {"anyOf": []} gives Any here, and Any via the type fallthrough without the guard.
If it's useful, the tests I had written cover a few cases beyond the ones here — both keywords parametrised over the same assertion, null-branch to Optional, anyOf agreeing with the equivalent {"type": ["integer","string"]} spelling, recursive branch mapping (anyOf of array/string to Union[List[int], str]), and the round trip above. Happy to open them as a follow-up against this branch rather than a competing PR.
Summary
schema_type_to_pythonhandledtype,enum,const, and type arrays, but silently fell through toAnyfor JSON SchemaanyOfandoneOf.That dropped all value constraints when
json_schema_dict_to_pydanticconverted a schema containing a union. Structured generation could then accept arbitrary values for a field whose schema specified a finite union.Fix
anyOf/oneOfbranchUnionOptional[...]oneOfexclusivity cannot be represented by PythonUnionwhen branches overlap, but retaining the allowed type union is strictly safer than widening toAny.Verification
pytest tests/types/test_json_schema_utils.py— 24 passedFixes #1950