-
Notifications
You must be signed in to change notification settings - Fork 235
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
Make validating assignment work properly with allowed extra #766
Conversation
let new_extra = match &self.extra_behavior { | ||
ExtraBehavior::Allow => { | ||
let non_extra_data = PyDict::new(py); | ||
self.fields.iter().for_each(|f| { | ||
let popped_value = PyAny::get_item(new_data, &f.name).unwrap(); | ||
new_data.del_item(&f.name).unwrap(); | ||
non_extra_data.set_item(&f.name, popped_value).unwrap(); | ||
}); | ||
let new_extra = new_data.copy()?; | ||
new_data.clear(); | ||
new_data.update(non_extra_data.as_mapping())?; | ||
new_extra.to_object(py) | ||
} | ||
_ => py.None(), | ||
}; |
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 feel there must be a better way to achieve this — @davidhewitt maybe you have some suggestions?
The idea — I'm trying to take new_data
, which will have all key-value pairs for fields and extra, and split them into two dicts — new_data
which has only field values, and new_extra
which has everything else.
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.
This looks pretty sound to me on the whole
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.
You're modifying new_data
so I don't think you need to create two new dicts. The challenge seems to be that you only have the list of known fields, so you are forced to remove them from new_data
.
How about swapping the binding over like this:
let new_extra = match &self.extra_behavior { | |
ExtraBehavior::Allow => { | |
let non_extra_data = PyDict::new(py); | |
self.fields.iter().for_each(|f| { | |
let popped_value = PyAny::get_item(new_data, &f.name).unwrap(); | |
new_data.del_item(&f.name).unwrap(); | |
non_extra_data.set_item(&f.name, popped_value).unwrap(); | |
}); | |
let new_extra = new_data.copy()?; | |
new_data.clear(); | |
new_data.update(non_extra_data.as_mapping())?; | |
new_extra.to_object(py) | |
} | |
_ => py.None(), | |
}; | |
let (new_data, new_extra) = match &self.extra_behavior { | |
ExtraBehavior::Allow => { | |
// Move non-extra keys out of new_data, leaving just the extra in new_data | |
let non_extra_data = PyDict::new(py); | |
for field in &self.fields | |
let popped_value = PyAny::get_item(new_data, &field.name).unwrap(); | |
new_data.del_item(&f.name).unwrap(); | |
non_extra_data.set_item(&f.name, popped_value).unwrap(); | |
} | |
(non_extra_data, new_data.to_object()) | |
} | |
// FIXME do you need to throw if `new_data` contains any extra keys? | |
_ => (new_data, py.None()), | |
}; |
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 check previously for any possible source of extra keys (and error), so I think we can ignore that potential issue here. So this looks good and we can drop 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.
Just kidding, this breaks some tests because we currently assume in some places that validate_assignment
does an in-place modification to the fields dict, but this makes it become the extra
dict. I'm not sure what the ideal behavior is here but I am inclined to leave as it was before for now, and we can revisit this in a separate PR if/when it seems worthwhile.
Codecov Report
Additional details and impacted files@@ Coverage Diff @@
## main #766 +/- ##
=======================================
Coverage 93.67% 93.67%
=======================================
Files 99 99
Lines 14270 14290 +20
Branches 25 25
=======================================
+ Hits 13367 13386 +19
- Misses 897 898 +1
Partials 6 6
Continue to review full report in Codecov by Sentry.
|
CodSpeed Performance ReportMerging #766 will not alter performanceComparing Summary
|
You are probably building with nightly rust - I suggest downgrading to stable :) (@adriangb had the same issue yesterday) |
let fields_set: &PySet = PySet::new(py, &[field_name.to_string()])?; | ||
Ok((new_data, py.None(), fields_set.to_object(py)).to_object(py)) | ||
Ok((new_data.to_object(py), new_extra, fields_set.to_object(py)).to_object(py)) |
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.
The return value of validate_assignment
in _pydantic_core.pyi
is dict[str, Any]
, but here we have a 3-tuple.
a) Should we change the return value of validate_assignment
in _pydantic_core.pyi
?
b) Should we change the definition of validate_assignment
in trait Validator
to return (PyObject, PyObject, PyObject)
? This will enforce all the Rust implementation to behave correctly and avoid needing to go in and out of a Python tuple until we get to the top level.
EDIT based on what I see in the tests, I think the answer to both of these is "yes".
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.
Should we change the definition of validate_assignment in trait Validator to return (PyObject, PyObject, PyObject)? This will enforce all the Rust implementation to behave correctly and avoid needing to go in and out of a Python tuple until we get to the top level.
Some historical perspective: validate_assignment
and validate
used to be the same thing, there was just a boolean flag being thrown around internally to differentiate the "mode". So the fact that the return is a PyObject
instead of a tuple of PyObject
is likely just an artifact of that original implementation.
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.
This validate_assignment is also used for dataclasses, and I think we need it to be a dict[str, Any]
for that case. I updated the type hint to be a union. I agree this probably deserves some cleanup but I think it's not as straightforward as "always return a tuple".
let new_extra = match &self.extra_behavior { | ||
ExtraBehavior::Allow => { | ||
let non_extra_data = PyDict::new(py); | ||
self.fields.iter().for_each(|f| { | ||
let popped_value = PyAny::get_item(new_data, &f.name).unwrap(); | ||
new_data.del_item(&f.name).unwrap(); | ||
non_extra_data.set_item(&f.name, popped_value).unwrap(); | ||
}); | ||
let new_extra = new_data.copy()?; | ||
new_data.clear(); | ||
new_data.update(non_extra_data.as_mapping())?; | ||
new_extra.to_object(py) | ||
} | ||
_ => py.None(), | ||
}; |
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.
You're modifying new_data
so I don't think you need to create two new dicts. The challenge seems to be that you only have the list of known fields, so you are forced to remove them from new_data
.
How about swapping the binding over like this:
let new_extra = match &self.extra_behavior { | |
ExtraBehavior::Allow => { | |
let non_extra_data = PyDict::new(py); | |
self.fields.iter().for_each(|f| { | |
let popped_value = PyAny::get_item(new_data, &f.name).unwrap(); | |
new_data.del_item(&f.name).unwrap(); | |
non_extra_data.set_item(&f.name, popped_value).unwrap(); | |
}); | |
let new_extra = new_data.copy()?; | |
new_data.clear(); | |
new_data.update(non_extra_data.as_mapping())?; | |
new_extra.to_object(py) | |
} | |
_ => py.None(), | |
}; | |
let (new_data, new_extra) = match &self.extra_behavior { | |
ExtraBehavior::Allow => { | |
// Move non-extra keys out of new_data, leaving just the extra in new_data | |
let non_extra_data = PyDict::new(py); | |
for field in &self.fields | |
let popped_value = PyAny::get_item(new_data, &field.name).unwrap(); | |
new_data.del_item(&f.name).unwrap(); | |
non_extra_data.set_item(&f.name, popped_value).unwrap(); | |
} | |
(non_extra_data, new_data.to_object()) | |
} | |
// FIXME do you need to throw if `new_data` contains any extra keys? | |
_ => (new_data, py.None()), | |
}; |
assert v.validate_assignment({'field_a': 'test'}, 'other_field', 456) == ( | ||
{'field_a': 'test', 'other_field': 456}, | ||
None, | ||
{'field_a': 'test'}, | ||
{'other_field': 456}, | ||
{'other_field'}, | ||
) |
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.
Based on this it looks like the type annotation in _pydantic_core.pyi
needs to be updated to a 3-tuple.
Approved conditional on fixing feedback |
Fixes pydantic/pydantic#6613. No changes are necessary on the pydantic side, though I will open a PR adding a test.
Many of the changes here were just removing unnecessary
.iter()
s to getmake
to run without errors after I ranrustup update
; it seems there are some new clippy lints.