Skip to content

fix(types): map anyOf/oneOf to a Union instead of dropping to Any - #1973

Closed
ErenAta16 wants to merge 1 commit into
dottxt-ai:mainfrom
ErenAta16:fix-anyof-oneof-union
Closed

fix(types): map anyOf/oneOf to a Union instead of dropping to Any#1973
ErenAta16 wants to merge 1 commit into
dottxt-ai:mainfrom
ErenAta16:fix-anyof-oneof-union

Conversation

@ErenAta16

Copy link
Copy Markdown
Contributor

Closes #1950.

What

schema_type_to_python dispatches on enum, const, type and JSON Schema type arrays, but not on anyOf / oneOf. A property spelled with either union keyword falls through every branch and returns Any, so the field ends up with no constraint.

That matters more than a hand-written schema suggests, because Pydantic emits anyOf for Optional[T] and Union[...]. A model round-tripped through json_schema_dict_to_pydantic loses exactly those fields:

class Source(BaseModel):
    maybe_int: Optional[int]
    either: Union[int, str]
    plain: int
field before after
maybe_int Any Optional[int]
either Any Union[int, str]
plain int int

and the regenerated schema goes from {"title": "Maybe Int"} back to {"anyOf": [{"type": "integer"}, {"type": "null"}], "title": "Maybe Int"} — identical to what Pydantic produced, so the round trip is lossless for these fields now.

The same constraint written as a type array already worked, which is what made the gap easy to miss:

{"type": ["integer", "string"]}                       -> Union[int, str]
{"anyOf": [{"type": "integer"}, {"type": "string"}]}  -> Any        (before)

How

Handle both keywords before the type dispatch, mapping each branch through schema_type_to_python recursively and combining with Union — the same shape the existing type-array branch uses.

oneOf maps to the same Union. Python typing can't express exclusivity, so an input matching two branches will validate where the schema says it shouldn't; that's a widening, but a far smaller one than Any, which accepts values no branch allows. Called out in a comment so the trade-off isn't silently inherited.

Tests

Five tests: both keywords to Union, both with a null branch to Optional, the two spellings agreeing, recursive branch mapping (anyOf of array/string to Union[List[int], str]), and the Pydantic round trip above.

They fail 7/30 on main and pass 30/30 with the change. tests/types/test_dsl.py is unchanged at 52 passed. ruff check is clean.

tests/types/test_custom_types.py fails to import in my environment because airportsdata isn't installed; that's unrelated and identical on a clean checkout.

Note

schema_type_to_python is also touched by #1970 (the additionalProperties branch inside elif t == "object"). These are different branches of the same function, so they'll conflict textually but not semantically if both land.

schema_type_to_python handled enum, const, type and JSON Schema type
arrays, but not anyOf or oneOf, so a property spelled with either union
keyword fell through to Any and carried no constraint at all.

Pydantic emits anyOf for Optional[T] and Union[...], so a model round
tripped through json_schema_dict_to_pydantic lost those fields entirely
while the equivalent type-array spelling kept them.

Map each branch recursively and combine with Union, matching what the
type-array path already does. oneOf maps to the same Union: typing cannot
express exclusivity, but the set of allowed types is strictly narrower
than Any.

Closes dottxt-ai#1950.
@github-actions

Copy link
Copy Markdown

📚 Documentation preview: https://dottxt-ai.github.io/outlines/pr-preview/pr-1973/

Preview updates automatically with each commit.

@ErenAta16

Copy link
Copy Markdown
Contributor Author

Closing this in favour of #1951, which does the same thing and was open nine days before I opened this. I should have found it — that's a preflight failure on my part, and the second one today.

Two differences worth recording for #1951 rather than losing here.

{"anyOf": []} — an empty branch list. #1951 reaches its members build and returns Any for it; this PR's if branches: treats the empty list as absent and falls through to the type dispatch, which also lands on Any. Same destination, so no action needed.

{"type": "object", "anyOf": [...]} — both keywords present. #1951's "type" not in schema guard skips the union and lets the type branch win; this PR ignored type and returned the union. #1951's behaviour is the safer one: JSON Schema treats them as constraints that both apply, and dropping the declared type in favour of the branches is a widening this fix doesn't need to make. So the guard is right, and I withdraw the version without it.

The extra tests here — both keywords parametrised, the null branch mapping to Optional, the two spellings agreeing, recursive branch mapping, and the Pydantic round trip — are the part I'd suggest porting to #1951 if any of them aren't covered there.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

schema_type_to_python drops anyOf/oneOf constraints to Any

1 participant