diff --git a/src/argument_markers.rs b/src/argument_markers.rs index 7a0b6268f..3d578bac0 100644 --- a/src/argument_markers.rs +++ b/src/argument_markers.rs @@ -60,10 +60,9 @@ impl PydanticUndefinedType { } #[staticmethod] - pub fn new(py: Python) -> Py { - UNDEFINED_CELL - .get_or_init(py, || Py::new(py, PydanticUndefinedType {}).unwrap()) - .clone_ref(py) + #[pyo3(name = "new")] + pub fn get(py: Python<'_>) -> &Py { + UNDEFINED_CELL.get_or_init(py, || Py::new(py, PydanticUndefinedType {}).unwrap()) } fn __repr__(&self) -> &'static str { diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 5a1b9a4b0..d3a26bfc5 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -833,7 +833,7 @@ impl<'py> KeywordArgs<'py> for PyKwargs<'py> { &self, key: &'k crate::lookup_key::LookupKey, ) -> ValResult)>> { - key.py_get_dict_item(&self.0) + key.py_get_dict_item(&self.0).map_err(Into::into) } fn iter(&self) -> impl Iterator, Self::Item<'_>)>> { @@ -864,8 +864,8 @@ impl<'py> ValidatedDict<'py> for GenericPyMapping<'_, 'py> { key: &'k crate::lookup_key::LookupKey, ) -> ValResult)>> { match self { - Self::Dict(dict) => key.py_get_dict_item(dict), - Self::Mapping(mapping) => key.py_get_mapping_item(mapping), + Self::Dict(dict) => key.py_get_dict_item(dict).map_err(Into::into), + Self::Mapping(mapping) => key.py_get_mapping_item(mapping).map_err(Into::into), Self::GetAttr(obj, dict) => key.py_get_attr(obj, dict.as_ref()), } } diff --git a/src/lib.rs b/src/lib.rs index ff55ea9ef..c8bb5d943 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -125,7 +125,7 @@ pub mod _pydantic_core { m.add("build_profile", env!("PROFILE"))?; m.add("build_info", build_info())?; m.add("_recursion_limit", recursion_guard::RECURSION_GUARD_LIMIT)?; - m.add("PydanticUndefined", PydanticUndefinedType::new(m.py()))?; + m.add("PydanticUndefined", PydanticUndefinedType::get(m.py()))?; Ok(()) } } diff --git a/src/lookup_key.rs b/src/lookup_key.rs index 7fe5d61e9..525dd3bb9 100644 --- a/src/lookup_key.rs +++ b/src/lookup_key.rs @@ -11,7 +11,7 @@ use jiter::{JsonObject, JsonValue}; use crate::build_tools::py_schema_err; use crate::errors::{py_err_string, ErrorType, LocItem, Location, ToErrorValue, ValError, ValLineError, ValResult}; use crate::input::StringMapping; -use crate::tools::{extract_i64, py_err}; +use crate::tools::{extract_i64, mapping_get, py_err}; /// Used for getting items from python dicts, python objects, or JSON objects, in different ways #[derive(Debug)] @@ -89,44 +89,12 @@ impl LookupKey { pub fn py_get_dict_item<'py, 's>( &'s self, dict: &Bound<'py, PyDict>, - ) -> ValResult)>> { - match self { - Self::Simple(path) => match dict.get_item(&path.first_item.py_key)? { - Some(value) => { - debug_assert!(path.rest.is_empty()); - Ok(Some((path, value))) - } - None => Ok(None), - }, - Self::Choice { path1, path2, .. } => match dict.get_item(&path1.first_item.py_key)? { - Some(value) => { - debug_assert!(path1.rest.is_empty()); - Ok(Some((path1, value))) - } - None => match dict.get_item(&path2.first_item.py_key)? { - Some(value) => { - debug_assert!(path2.rest.is_empty()); - Ok(Some((path2, value))) - } - None => Ok(None), - }, - }, - Self::PathChoices(path_choices) => { - for path in path_choices { - let Some(first_value) = dict.get_item(&path.first_item.py_key)? else { - continue; - }; - // iterate over the path and plug each value into the py_any from the last step, - // this could just be a loop but should be somewhat faster with a functional design - if let Some(v) = path.rest.iter().try_fold(first_value, |d, loc| loc.py_get_item(&d)) { - // Successfully found an item, return it - return Ok(Some((path, v))); - } - } - // got to the end of path_choices, without a match, return None - Ok(None) - } - } + ) -> PyResult)>> { + self.get_impl( + dict, + |dict, path| dict.get_item(&path.py_key), + |d, loc| Ok(loc.py_get_item(&d)), + ) } pub fn py_get_string_mapping_item<'py, 's>( @@ -144,94 +112,23 @@ impl LookupKey { pub fn py_get_mapping_item<'py, 's>( &'s self, dict: &Bound<'py, PyMapping>, - ) -> ValResult)>> { - match self { - Self::Simple(path) => match dict.get_item(&path.first_item.py_key) { - Ok(value) => { - debug_assert!(path.rest.is_empty()); - Ok(Some((path, value))) - } - _ => Ok(None), - }, - Self::Choice { path1, path2, .. } => match dict.get_item(&path1.first_item.py_key) { - Ok(value) => { - debug_assert!(path1.rest.is_empty()); - Ok(Some((path1, value))) - } - _ => match dict.get_item(&path2.first_item.py_key) { - Ok(value) => { - debug_assert!(path2.rest.is_empty()); - Ok(Some((path2, value))) - } - _ => Ok(None), - }, - }, - Self::PathChoices(path_choices) => { - for path in path_choices { - let Some(first_value) = dict.get_item(&path.first_item.py_key).ok() else { - continue; - }; - // iterate over the path and plug each value into the py_any from the last step, - // this could just be a loop but should be somewhat faster with a functional design - if let Some(v) = path.rest.iter().try_fold(first_value, |d, loc| loc.py_get_item(&d)) { - // Successfully found an item, return it - return Ok(Some((path, v))); - } - } - // got to the end of path_choices, without a match, return None - Ok(None) - } - } + ) -> PyResult)>> { + self.get_impl( + dict, + |dict, path| mapping_get(dict, &path.py_key), + |d, loc| Ok(loc.py_get_item(&d)), + ) } pub fn simple_py_get_attr<'py, 's>( &'s self, obj: &Bound<'py, PyAny>, ) -> PyResult)>> { - match self { - Self::Simple(path) => match py_get_attrs(obj, &path.first_item.py_key)? { - Some(value) => { - debug_assert!(path.rest.is_empty()); - Ok(Some((path, value))) - } - None => Ok(None), - }, - Self::Choice { path1, path2, .. } => match py_get_attrs(obj, &path1.first_item.py_key)? { - Some(value) => { - debug_assert!(path1.rest.is_empty()); - Ok(Some((path1, value))) - } - None => match py_get_attrs(obj, &path2.first_item.py_key)? { - Some(value) => { - debug_assert!(path2.rest.is_empty()); - Ok(Some((path2, value))) - } - None => Ok(None), - }, - }, - Self::PathChoices(path_choices) => { - 'outer: for path in path_choices { - // similar to above, but using `py_get_attrs`, we can't use try_fold because of the extra Err - // so we have to loop manually - let Some(mut v) = path.first_item.py_get_attrs(obj)? else { - continue; - }; - for loc in &path.rest { - v = match loc.py_get_attrs(&v) { - Ok(Some(v)) => v, - Ok(None) => { - continue 'outer; - } - Err(e) => return Err(e), - } - } - // Successfully found an item, return it - return Ok(Some((path, v))); - } - // got to the end of path_choices, without a match, return None - Ok(None) - } - } + self.get_impl( + obj, + |obj, path| py_get_attrs(obj, &path.py_key), + |d, loc| loc.py_get_attrs(&d), + ) } pub fn py_get_attr<'py, 's>( @@ -324,6 +221,57 @@ impl LookupKey { } } + fn get_impl<'s, 'a, SourceT, OutputT: 'a>( + &'s self, + source: &'a SourceT, + lookup: impl Fn(&'a SourceT, &'s PathItemString) -> PyResult>, + nested_lookup: impl Fn(OutputT, &'s PathItem) -> PyResult>, + ) -> PyResult> { + match self { + Self::Simple(path) => match lookup(source, &path.first_item)? { + Some(value) => { + debug_assert!(path.rest.is_empty()); + Ok(Some((path, value))) + } + None => Ok(None), + }, + Self::Choice { path1, path2, .. } => match lookup(source, &path1.first_item)? { + Some(value) => { + debug_assert!(path1.rest.is_empty()); + Ok(Some((path1, value))) + } + None => match lookup(source, &path2.first_item)? { + Some(value) => { + debug_assert!(path2.rest.is_empty()); + Ok(Some((path2, value))) + } + None => Ok(None), + }, + }, + Self::PathChoices(path_choices) => { + 'choices: for path in path_choices { + let Some(mut value) = lookup(source, &path.first_item)? else { + continue; + }; + + // iterate over the path and plug each value into the value from the last step + for loc in &path.rest { + value = match nested_lookup(value, loc) { + Ok(Some(v)) => v, + // this choice did not match, try the next one + Ok(None) => continue 'choices, + Err(e) => return Err(e), + } + } + // Successfully found an item, return it + return Ok(Some((path, value))); + } + // got to the end of path_choices, without a match, return None + Ok(None) + } + } + } + pub fn error( &self, error_type: ErrorType, diff --git a/src/tools.rs b/src/tools.rs index edf9b6bac..8ce929c51 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -4,10 +4,11 @@ use num_bigint::BigInt; use pyo3::exceptions::PyKeyError; use pyo3::prelude::*; -use pyo3::types::{PyDict, PyString}; +use pyo3::types::{PyDict, PyMapping, PyString}; use pyo3::{intern, FromPyObject}; use crate::input::Int; +use crate::PydanticUndefinedType; use jiter::{cached_py_string, StringCacheMode}; pub trait SchemaDict<'py> { @@ -190,3 +191,14 @@ pub fn write_truncated_to_limited_bytes(f: &mut F, val: &str, max write!(f, "{val}") } } + +/// Implementation of `mapping.get(key, PydanticUndefined)` which returns `None` if the key is not found +pub fn mapping_get<'py>( + mapping: &Bound<'py, PyMapping>, + key: impl IntoPyObject<'py>, +) -> PyResult>> { + let undefined = PydanticUndefinedType::get(mapping.py()); + mapping + .call_method1(intern!(mapping.py(), "get"), (key, undefined)) + .map(|value| if value.is(undefined) { None } else { Some(value) }) +} diff --git a/src/validators/model.rs b/src/validators/model.rs index 5f7a5970d..e4dbb128b 100644 --- a/src/validators/model.rs +++ b/src/validators/model.rs @@ -101,7 +101,7 @@ impl BuildValidator for ModelValidator { frozen: schema.get_as(intern!(py, "frozen"))?.unwrap_or(false), custom_init: schema.get_as(intern!(py, "custom_init"))?.unwrap_or(false), root_model: schema.get_as(intern!(py, "root_model"))?.unwrap_or(false), - undefined: PydanticUndefinedType::new(py).into_any(), + undefined: PydanticUndefinedType::get(py).clone_ref(schema.py()).into_any(), // Get the class's `__name__`, not using `class.qualname()` name, }) diff --git a/src/validators/with_default.rs b/src/validators/with_default.rs index dc881d5a1..764395283 100644 --- a/src/validators/with_default.rs +++ b/src/validators/with_default.rs @@ -144,7 +144,7 @@ impl BuildValidator for WithDefaultValidator { validate_default: schema_or_config_same(schema, config, intern!(py, "validate_default"))?.unwrap_or(false), copy_default, name, - undefined: PydanticUndefinedType::new(py).into_any(), + undefined: PydanticUndefinedType::get(py).clone_ref(schema.py()).into_any(), }) .into()) } @@ -187,7 +187,7 @@ impl Validator for WithDefaultValidator { // in an unhelpul error. let mut err = ValError::new( ErrorTypeDefaults::DefaultFactoryNotCalled, - PydanticUndefinedType::new(py).into_bound(py).into_any(), + PydanticUndefinedType::get(py).bind(py).clone().into_any(), ); if let Some(outer_loc) = outer_loc { err = err.with_outer_location(outer_loc); diff --git a/tests/validators/test_model.py b/tests/validators/test_model.py index 179b75304..5cd7634df 100644 --- a/tests/validators/test_model.py +++ b/tests/validators/test_model.py @@ -1,5 +1,6 @@ import re import sys +from collections import defaultdict from copy import deepcopy from decimal import Decimal from typing import Any, Callable, Union @@ -1359,3 +1360,29 @@ class MyModel: v.validate_assignment(m, 'enum_field', Decimal(1)) v.validate_assignment(m, 'enum_field_2', Decimal(2)) v.validate_assignment(m, 'enum_field_3', IntWrappable(3)) + + +def test_model_from_defaultdict(): + # https://github.com/pydantic/pydantic/issues/12376 + + class MyModel: + def __init__(self, **kwargs: Any) -> None: + self.__dict__.update(kwargs) + + schema = core_schema.model_schema( + MyModel, core_schema.model_fields_schema({'field_a': core_schema.model_field(core_schema.int_schema())}) + ) + + v = SchemaValidator(schema) + with pytest.raises(ValidationError) as exc_info: + # the defaultdict should not provide default values for missing fields + v.validate_python(defaultdict(int)) + + assert exc_info.value.errors(include_url=False) == [ + { + 'type': 'missing', + 'loc': ('field_a',), + 'msg': 'Field required', + 'input': defaultdict(int), + } + ]