Skip to content
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

Merged
merged 4 commits into from
Jul 13, 2023

Conversation

dmontagu
Copy link
Collaborator

@dmontagu dmontagu commented Jul 12, 2023

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 get make to run without errors after I ran rustup update; it seems there are some new clippy lints.

Comment on lines +375 to +389
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(),
};
Copy link
Collaborator Author

@dmontagu dmontagu Jul 12, 2023

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.

Copy link
Member

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

Copy link
Contributor

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:

Suggested change
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()),
};

Copy link
Collaborator Author

@dmontagu dmontagu Jul 13, 2023

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.

Copy link
Collaborator Author

@dmontagu dmontagu Jul 13, 2023

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
Copy link

codecov bot commented Jul 12, 2023

Codecov Report

Merging #766 (d39c591) into main (f5b804b) will increase coverage by 0.00%.
The diff coverage is 97.05%.

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           
Impacted Files Coverage Δ
src/validators/model.rs 98.03% <94.11%> (-0.35%) ⬇️
src/validators/model_fields.rs 98.42% <100.00%> (+0.10%) ⬆️

Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update f5b804b...d39c591. Read the comment docs.

@codspeed-hq
Copy link

codspeed-hq bot commented Jul 12, 2023

CodSpeed Performance Report

Merging #766 will not alter performance

Comparing validate-assignment-extra (d39c591) with main (f5b804b)

Summary

✅ 126 untouched benchmarks

@davidhewitt
Copy link
Contributor

Many of the changes here were just removing unnecessary .iter()s to get make to run without errors after I ran rustup update; it seems there are some new clippy lints.

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))
Copy link
Contributor

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".

Copy link
Member

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.

Copy link
Collaborator Author

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".

Comment on lines +375 to +389
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(),
};
Copy link
Contributor

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:

Suggested change
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()),
};

Comment on lines 349 to 353
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'},
)
Copy link
Contributor

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.

@adriangb
Copy link
Member

Approved conditional on fixing feedback

@dmontagu dmontagu enabled auto-merge (squash) July 13, 2023 17:53
@dmontagu dmontagu merged commit 3f7c010 into main Jul 13, 2023
27 checks passed
@dmontagu dmontagu deleted the validate-assignment-extra branch July 13, 2023 17:59
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.

Updated value for extra attribute differs from value in model_dump if validate_assignment enabled
3 participants