-
Notifications
You must be signed in to change notification settings - Fork 250
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improvements in union matching logic during validation #1332
Conversation
CodSpeed Performance ReportMerging #1332 will not alter performanceComparing Summary
|
This happens to fix pydantic/pydantic#8690, but really this belongs in the "want to fix" category below, as we need to account for defaults more carefully. Interestingly, after writing this PR, I noticed that a similar suggestion for a fix was made in pydantic/pydantic#9095. We don't exactly use There's still more to be done here, specifically, we can fix the below issues if we address correctness of a match based on model fields set, and not defaults used. I do think that issues with default handling should also be addressed in this PR, because without that, existing test cases are failing with the current naive addition. It would be exciting to close 9 issues with one PR 😂. This speaks to both our lack of ability to close duplicates, but also is helpful in terms of inspiration for a variety of test cases! Thus, I plan tomorrow to experiment with the
Want to fix:
|
Update, I believe I have now fixed the following:
That being said, I'd definitely be open to reworking this design in general. Specifically, I'm wondering:
|
Things I still definitely need to do here:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's really exciting to see progress in this area!
So I will be honest and say that I'm quite scared about all possible solutions in this space and how they generalise to meet every users' expectations.
Especially the more complex we make this logic, the more we risk that users will find edge cases which don't work as they hope and we have to add even more complexity to this process to meet their needs. Where does the line get drawn, and how do we document this process to users? At the moment smart mode unions are very opaque and I think we've permitted ourselves to adjust their behaviour in future releases as long as we believe the new behaviour is better, but it's a fine line.
Using this heuristic solves a lot of the current reports so it seems like a win and we should move forward; let's just tread carefully.
It doesn't need implementing right away, but it'd also be good for us to have an idea of what we could offer as a Python API for users to customise this heuristic. Maybe that's a can of worms, but it'd also finally give us a way to deal with a load of the user cases here.
🎈
Yep, this all makes sense to me. I think that because we have some flexibility with what "smart" means, we can make this change. Additionally, users who have specialized cases that behave differently with this change can get the old behavior by using
👍
I like this idea. At least for now, custom validators provide some sort of shortcut for this. |
I'll note, pydantic/pydantic#9101 seems like a different issue. |
Now using I still need to add support for this to |
src/validators/union.rs
Outdated
// been one success, we turn on strict mode, to avoid unnecessary | ||
// coercions for further validation? | ||
success = Some((new_success, new_exactness)); | ||
let new_fields_set = state.fields_set_count; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we probably need to reset fields_set_count
between each loop turn? (e.g. like exactness is reset on line 124).
Propagating on the state like this potentially has surprising effects like list[ModelA] | list[ModelB]
probably now gets chosen based on whether the last entry fitted ModelA
or ModelB
better. We could deal with that by explicitly clearing num_fields_set
in compound validators like list validators.
Overall I'm fine with this approach of using state, I think either this or num_fields
(and exactness
) all are fiddly to implement and will likely just need lots of hardening via test cases.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Behavior looks good for the list case you've mentioned above:
from pydantic import BaseModel, TypeAdapter
class ModelA(BaseModel):
a: int
class ModelB(ModelA):
b: int
ta = TypeAdapter(list[ModelA] | list[ModelB])
print(repr(ta.validate_python([{'a': 1}, {'a': 2}])))
#> ModelA(a=1), ModelA(a=2)]
print(repr(ta.validate_python([{'a': 1, 'b': 2}, {'a': 2, 'b': 3}])))
#> [ModelB(a=1, b=2), ModelB(a=2, b=3)]
print(repr(ta.validate_python([{'a': 1, 'b': 2}, {'a': 2}])))
#> [ModelA(a=1), ModelA(a=2)]
print(repr(ta.validate_python([{'a': 1}, {'a': 2, 'b': 3}])))
#> [ModelA(a=1), ModelA(a=2)]
I think this is because I'm now clearing the relevant parts of the state, and fields_set_count
is by default None
, in which case we only consider exactness
.
As discussed with @dmontagu offline, I made some changes so that from pydantic import BaseModel, TypeAdapter
class SubModelA(BaseModel):
x: int = 1
class SubModelB(BaseModel):
y: int = 2
class ModelA(BaseModel):
sub: SubModelA
class ModelB(BaseModel):
sub: SubModelB
ta = TypeAdapter(ModelA | ModelB)
print(repr(ta.validate_python({'sub': {'y': 3}})))
#> Before: ModelA(sub=SubModelA(x=1))
#> After: ModelB(sub=SubModelB(y=3)) |
I've also added a bunch of new test cases that verify we're getting the desired behavior associated with this change. |
src/validators/union.rs
Outdated
let new_success_is_best_match: bool = | ||
success.as_ref().map_or(true, |(_, cur_exactness, cur_fields_set)| { | ||
match (*cur_fields_set, new_fields_set) { | ||
(Some(cur), Some(new)) if cur != new => cur < new, | ||
_ => *cur_exactness < new_exactness, | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we add a comment here describing the logic? I read it as follows:
- We prefer number of fields to exactness
- In case where number of fields is tied, or one side doesn't have number of fields, we use exactness
What about ModelA | dict[str, int]
? If we pass {'a': 2, 'b': 3}
as input, then according to the above the dict
validator doesn't currently have number of fields, and will win based on exactness.
I think instead if we passed {'a': '2', 'b': '3'}
with string keys, the exactness is the same (lax) and so ModelA
probably wins as the leftmost member.
Maybe dict
validators should also count fields set?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've added the comment :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's correct re current behavior:
from pydantic import BaseModel, TypeAdapter
class DictModel(BaseModel):
a: int
b: int
ta = TypeAdapter(DictModel | dict[str, int])
print(repr(ta.validate_python({'a': 1, 'b': 2})))
#> {'a': 1, 'b': 2}
print(repr(ta.validate_python({'a': '1', 'b': '2'})))
#> DictModel(a=1, b=2)
print(repr(ta.validate_python(DictModel(a=1, b=2))))
#> DictModel(a=1, b=2)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly, I think this might be a case where we suggest that users use left-to-right
or a discriminator if they really want to customize behavior.
src/validators/model.rs
Outdated
force_setattr(py, self_instance, intern!(py, ROOT_FIELD), &output)?; | ||
state.fields_set_count = Some(fields_set.len() + state.fields_set_count.unwrap_or(0)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think by adding in this way, when we consider a list we're summing up the models across all list items:
[{'a': 1, 'b': 2}, {'a': 2, 'b': 3}] # total of ModelA is 2, total of ModelB is 4, so ModelB wins
If I follow correctly, that means that stuff like how many defaulted members exist swing the outcome depending on the input length. I get the following characteristics out where the list can flip to A1
or A2
type based on the count of total successful fields:
from pydantic import TypeAdapter, BaseModel
class A1(BaseModel):
a: int
b: int = 0
class A2(BaseModel):
a: int
c: int = 0
d: int = 0
fits_a1_better = {'a': 0, 'b': 0}
fits_a2_better = {'a': 0, 'c': 0, 'd': 0}
validate = TypeAdapter(list[A1] | list[A2]).validate_python
print(validate([fits_a1_better, fits_a2_better]))
#> [A2(a=0, c=0, d=0), A2(a=0, c=0, d=0)]
print(validate([fits_a1_better, fits_a1_better, fits_a2_better]))
#> [A1(a=0, b=0), A1(a=0, b=0), A1(a=0, b=0)]
print(validate([fits_a1_better, fits_a1_better, fits_a2_better, fits_a2_better]))
#> [A2(a=0, c=0, d=0), A2(a=0, c=0, d=0), A2(a=0, c=0, d=0), A2(a=0, c=0, d=0)]
... is this ok? Unsure, but it's a less intuitive result than current behaviour, I think. This is why I think list validators might want to remove fields_set
. And then there's a whole load of validators with similar problems 😥
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What other validators would you also like to disable this metric for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another case where left_to_right
helps:
from pydantic import TypeAdapter, BaseModel, Field
from typing import Annotated
class A1(BaseModel):
a: int
b: int = 0
class A2(BaseModel):
a: int
c: int = 0
d: int = 0
fits_a1_better = {'a': 0, 'b': 0}
fits_a2_better = {'a': 0, 'c': 0, 'd': 0}
validate = TypeAdapter(Annotated[list[A1] | list[A2], Field(union_mode='left_to_right')]).validate_python
print(validate([fits_a1_better, fits_a2_better]))
#> [A1(a=0, b=0), A1(a=0, b=0)]
print(validate([fits_a1_better, fits_a1_better, fits_a2_better]))
#> [A1(a=0, b=0), A1(a=0, b=0), A1(a=0, b=0)]
print(validate([fits_a1_better, fits_a1_better, fits_a2_better, fits_a2_better]))
#> [A1(a=0, b=0), A1(a=0, b=0), A1(a=0, b=0), A1(a=0, b=0)]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What other validators would you also like to disable this metric for?
I think basically every container of variable size has the same problem? sets, lists, tuples, dicts etc
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah. Makes sense. I think for now we can move forward with the current fix and add a docs update for the left_to_right
suggestions.
I'll note, I'll accompany this with a docs change on the |
This is to get pydantic/pydantic-core#1332 and can be switched back to the next release of pydantic after v2.8.2
This is to get pydantic/pydantic-core#1332 and can be switched back to the next release of pydantic after v2.8.2
This is to get pydantic/pydantic-core#1332 and can be switched back to the next release of pydantic after v2.8.2
Why === An upstream bugfix in pydantic/pydantic-core#1332 isn't able to be installed due to tight version pinning in [`[email protected]/pyproject.toml#L53`](https://github.com/pydantic/pydantic/blob/v2.8.2/pyproject.toml#L53). For the time being, just switch to the latest release in the pydantic repo as of the time of writing, which appears to resolve the generation issue I'm seeing when running the codegen. ![image](https://github.com/user-attachments/assets/b85ea67c-91cc-455c-bcc1-176cce9610a8) What changed ============ - Set pydantic version to track git @ pydantic/pydantic@f5d6acf Test plan ========= Manually running the codegen against production schemas appears to generate sensible results
See #65 for context, or pydantic/pydantic-core#1332 directly. The issue we were seeing was specifically around union decoders failing to decode properties of the selected variant, so even though we'd get the right model it wouldn't be filled out.
Why === See #65 for context, or pydantic/pydantic-core#1332 directly. The issue we were seeing was specifically around union decoders failing to decode properties of the selected variant, so even though we'd get the right model it wouldn't be filled out. What changed ============ Bump pydantic to the latest released beta Test plan ========= Run codegen, observe that the generated code matches the previously generated code to a reasonable degree.
This PR attempts to augment the current
union
validation logic by adding in tie-breaker logic for union members that tie in terms ofexactness
.The current naive approach is such that when validating a model type data (including dataclasses and typed dicts), we count the number of fields attached to said validator. Matches with more fields beat out those with fewer to help us deal with cases of subclasses, etc.
Fix pydantic/pydantic#9094
Fix pydantic/pydantic#8690
Fix pydantic/pydantic#7223
Fix pydantic/pydantic#9224
Fix pydantic/pydantic#7085
Fix pydantic/pydantic#6637
Fix pydantic/pydantic#9664
Almost fixes the problems in pydantic/pydantic#9095, but not entirely.