diff --git a/pydantic_core/core_schema.py b/pydantic_core/core_schema.py index 386d1bc16..eda6055e3 100644 --- a/pydantic_core/core_schema.py +++ b/pydantic_core/core_schema.py @@ -1173,7 +1173,7 @@ def multi_host_url_schema( 'string_too_long', 'string_pattern_mismatch', 'dict_type', - 'dict_from_mapping', + 'mapping_type', 'list_type', 'tuple_type', 'set_type', diff --git a/src/errors/types.rs b/src/errors/types.rs index 7a300ef29..77f5cbf29 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -151,9 +151,9 @@ pub enum ErrorType { // dict errors #[strum(message = "Input should be a valid dictionary")] DictType, - #[strum(message = "Unable to convert mapping to a dictionary, error: {error}")] - DictFromMapping { - error: String, + #[strum(message = "Input should be a valid mapping, error: {error}")] + MappingType { + error: Cow<'static, str>, }, // --------------------- // list errors @@ -448,7 +448,7 @@ impl ErrorType { Self::StringTooShort { .. } => extract_context!(StringTooShort, ctx, min_length: usize), Self::StringTooLong { .. } => extract_context!(StringTooLong, ctx, max_length: usize), Self::StringPatternMismatch { .. } => extract_context!(StringPatternMismatch, ctx, pattern: String), - Self::DictFromMapping { .. } => extract_context!(DictFromMapping, ctx, error: String), + Self::MappingType { .. } => extract_context!(Cow::Owned, MappingType, ctx, error: String), Self::BytesTooShort { .. } => extract_context!(BytesTooShort, ctx, min_length: usize), Self::BytesTooLong { .. } => extract_context!(BytesTooLong, ctx, max_length: usize), Self::ValueError { .. } => extract_context!(ValueError, ctx, error: String), @@ -540,7 +540,7 @@ impl ErrorType { Self::StringTooShort { min_length } => to_string_render!(self, min_length), Self::StringTooLong { max_length } => to_string_render!(self, max_length), Self::StringPatternMismatch { pattern } => render!(self, pattern), - Self::DictFromMapping { error } => render!(self, error), + Self::MappingType { error } => render!(self, error), Self::BytesTooShort { min_length } => to_string_render!(self, min_length), Self::BytesTooLong { max_length } => to_string_render!(self, max_length), Self::ValueError { error } => render!(self, error), @@ -593,7 +593,7 @@ impl ErrorType { Self::StringTooShort { min_length } => py_dict!(py, min_length), Self::StringTooLong { max_length } => py_dict!(py, max_length), Self::StringPatternMismatch { pattern } => py_dict!(py, pattern), - Self::DictFromMapping { error } => py_dict!(py, error), + Self::MappingType { error } => py_dict!(py, error), Self::BytesTooShort { min_length } => py_dict!(py, min_length), Self::BytesTooLong { max_length } => py_dict!(py, max_length), Self::ValueError { error } => py_dict!(py, error), diff --git a/src/input/input_python.rs b/src/input/input_python.rs index fa1f2dfe2..a790132db 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -1,18 +1,17 @@ use std::borrow::Cow; use std::str::from_utf8; -use pyo3::exceptions::PyAttributeError; use pyo3::once_cell::GILOnceCell; use pyo3::prelude::*; use pyo3::types::{ PyBool, PyByteArray, PyBytes, PyDate, PyDateTime, PyDelta, PyDict, PyFrozenSet, PyIterator, PyList, PyMapping, - PySequence, PySet, PyString, PyTime, PyTuple, PyType, + PySet, PyString, PyTime, PyTuple, PyType, }; #[cfg(not(PyPy))] use pyo3::types::{PyDictItems, PyDictKeys, PyDictValues}; use pyo3::{ffi, intern, AsPyPointer, PyTypeInfo}; -use crate::errors::{py_err_string, ErrorType, InputValue, LocItem, ValError, ValLineError, ValResult}; +use crate::errors::{ErrorType, InputValue, LocItem, ValError, ValLineError, ValResult}; use crate::{PyMultiHostUrl, PyUrl}; use super::datetime::{ @@ -329,8 +328,8 @@ impl<'a> Input<'a> for PyAny { fn lax_dict(&'a self) -> ValResult> { if let Ok(dict) = self.cast_as::() { Ok(dict.into()) - } else if let Some(generic_mapping) = mapping_as_dict(self) { - generic_mapping + } else if let Ok(mapping) = self.cast_as::() { + Ok(mapping.into()) } else { Err(ValError::new(ErrorType::DictType, self)) } @@ -342,9 +341,8 @@ impl<'a> Input<'a> for PyAny { if let Ok(dict) = self.cast_as::() { return Ok(dict.into()); } else if !strict { - // we can't do this in one set of if/else because we need to check from_mapping before doing this - if let Some(generic_mapping) = mapping_as_dict(self) { - return generic_mapping; + if let Ok(mapping) = self.cast_as::() { + return Ok(mapping.into()); } } @@ -643,47 +641,6 @@ impl<'a> Input<'a> for PyAny { } } -/// return None if obj is not a mapping (cast_as:: fails or mapping.items returns an AttributeError) -/// otherwise try to covert the mapping to a dict and return an Some(error) if it fails -fn mapping_as_dict(obj: &PyAny) -> Option> { - let mapping: &PyMapping = match obj.cast_as() { - Ok(mapping) => mapping, - Err(_) => return None, - }; - // see https://github.com/PyO3/pyo3/issues/2072 - the cast_as:: is not entirely accurate - // and returns some things which are definitely not mappings (e.g. str) as mapping, - // hence we also require that the object as `items` to consider it a mapping - let result_dict = match mapping.items() { - Ok(seq) => mapping_seq_as_dict(seq), - Err(err) => { - if matches!(err.get_type(obj.py()).is_subclass_of::(), Ok(true)) { - return None; - } else { - Err(err) - } - } - }; - match result_dict { - Ok(dict) => Some(Ok(dict.into())), - Err(err) => Some(Err(ValError::new( - ErrorType::DictFromMapping { - error: py_err_string(obj.py(), err), - }, - obj, - ))), - } -} - -// creating a temporary dict is slow, we could perhaps use an indexmap instead -fn mapping_seq_as_dict(seq: &PySequence) -> PyResult<&PyDict> { - let dict = PyDict::new(seq.py()); - for r in seq.iter()? { - let (key, value): (&PyAny, &PyAny) = r?.extract()?; - dict.set_item(key, value)?; - } - Ok(dict) -} - /// Best effort check of whether it's likely to make sense to inspect obj for attributes and iterate over it /// with `obj.dir()` fn from_attributes_applicable(obj: &PyAny) -> bool { diff --git a/src/input/mod.rs b/src/input/mod.rs index 53154a8e0..a45e5327d 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -14,8 +14,9 @@ pub use datetime::{EitherDate, EitherDateTime, EitherTime, EitherTimedelta}; pub use input_abstract::Input; pub use parse_json::{JsonInput, JsonObject, JsonType}; pub use return_enums::{ - py_string_str, EitherBytes, EitherString, GenericArguments, GenericCollection, GenericIterator, GenericMapping, - JsonArgs, PyArgs, + py_string_str, AttributesGenericIterator, DictGenericIterator, EitherBytes, EitherString, GenericArguments, + GenericCollection, GenericIterator, GenericMapping, JsonArgs, JsonObjectGenericIterator, MappingGenericIterator, + PyArgs, }; pub fn repr_string(v: &PyAny) -> PyResult { diff --git a/src/input/return_enums.rs b/src/input/return_enums.rs index fe95eddf1..bc28fd2b7 100644 --- a/src/input/return_enums.rs +++ b/src/input/return_enums.rs @@ -1,7 +1,16 @@ use std::borrow::Cow; +use pyo3::intern; use pyo3::prelude::*; -use pyo3::types::{PyBytes, PyDict, PyFrozenSet, PyIterator, PyList, PySet, PyString, PyTuple}; +use pyo3::types::iter::PyDictIterator; +use pyo3::types::{PyBytes, PyDict, PyFrozenSet, PyIterator, PyList, PyMapping, PySet, PyString, PyTuple}; + +#[cfg(not(PyPy))] +use pyo3::types::PyFunction; +#[cfg(not(PyPy))] +use pyo3::PyTypeInfo; + +use indexmap::map::Iter; use crate::errors::{py_err_string, ErrorType, InputValue, ValError, ValLineError, ValResult}; use crate::recursion_guard::RecursionGuard; @@ -233,14 +242,187 @@ impl<'a> GenericCollection<'a> { #[cfg_attr(debug_assertions, derive(Debug))] pub enum GenericMapping<'a> { PyDict(&'a PyDict), + PyMapping(&'a PyMapping), PyGetAttr(&'a PyAny), JsonObject(&'a JsonObject), } derive_from!(GenericMapping, PyDict, PyDict); +derive_from!(GenericMapping, PyMapping, PyMapping); derive_from!(GenericMapping, PyGetAttr, PyAny); derive_from!(GenericMapping, JsonObject, JsonObject); +pub struct DictGenericIterator<'py> { + dict_iter: PyDictIterator<'py>, +} + +impl<'py> DictGenericIterator<'py> { + pub fn new(dict: &'py PyDict) -> ValResult<'py, Self> { + Ok(Self { dict_iter: dict.iter() }) + } +} + +impl<'py> Iterator for DictGenericIterator<'py> { + type Item = ValResult<'py, (&'py PyAny, &'py PyAny)>; + + fn next(&mut self) -> Option { + self.dict_iter.next().map(Ok) + } + // size_hint is omitted as it isn't needed +} + +pub struct MappingGenericIterator<'py> { + input: &'py PyAny, + iter: &'py PyIterator, +} + +fn mapping_err<'py>(err: PyErr, py: Python<'py>, input: &'py impl Input<'py>) -> ValError<'py> { + ValError::new( + ErrorType::MappingType { + error: py_err_string(py, err).into(), + }, + input, + ) +} + +impl<'py> MappingGenericIterator<'py> { + pub fn new(mapping: &'py PyMapping) -> ValResult<'py, Self> { + let py = mapping.py(); + let input: &PyAny = mapping; + let iter = mapping + .items() + .map_err(|e| mapping_err(e, py, input))? + .iter() + .map_err(|e| mapping_err(e, py, input))?; + Ok(Self { iter, input }) + } +} + +const MAPPING_TUPLE_ERROR: &str = "Mapping items must be tuples of (key, value) pairs"; + +impl<'py> Iterator for MappingGenericIterator<'py> { + type Item = ValResult<'py, (&'py PyAny, &'py PyAny)>; + + fn next(&mut self) -> Option { + let item = match self.iter.next() { + Some(Err(e)) => return Some(Err(mapping_err(e, self.iter.py(), self.input))), + Some(Ok(item)) => item, + None => return None, + }; + let tuple: &PyTuple = match item.cast_as() { + Ok(tuple) => tuple, + Err(_) => { + return Some(Err(ValError::new( + ErrorType::MappingType { + error: MAPPING_TUPLE_ERROR.into(), + }, + self.input, + ))) + } + }; + if tuple.len() != 2 { + return Some(Err(ValError::new( + ErrorType::MappingType { + error: MAPPING_TUPLE_ERROR.into(), + }, + self.input, + ))); + }; + #[cfg(PyPy)] + let key = tuple.get_item(0).unwrap(); + #[cfg(PyPy)] + let value = tuple.get_item(1).unwrap(); + #[cfg(not(PyPy))] + let key = unsafe { tuple.get_item_unchecked(0) }; + #[cfg(not(PyPy))] + let value = unsafe { tuple.get_item_unchecked(1) }; + Some(Ok((key, value))) + } + // size_hint is omitted as it isn't needed +} + +pub struct AttributesGenericIterator<'py> { + object: &'py PyAny, + attributes: &'py PyList, + index: usize, +} + +impl<'py> AttributesGenericIterator<'py> { + pub fn new(py_any: &'py PyAny) -> ValResult<'py, Self> { + Ok(Self { + object: py_any, + attributes: py_any.dir(), + index: 0, + }) + } +} + +impl<'py> Iterator for AttributesGenericIterator<'py> { + type Item = ValResult<'py, (&'py PyAny, &'py PyAny)>; + + fn next(&mut self) -> Option { + // loop until we find an attribute who's name does not start with underscore, + // or we get to the end of the list of attributes + while self.index < self.attributes.len() { + #[cfg(PyPy)] + let name: &PyAny = self.attributes.get_item(self.index).unwrap(); + #[cfg(not(PyPy))] + let name: &PyAny = unsafe { self.attributes.get_item_unchecked(self.index) }; + self.index += 1; + // from benchmarks this is 14x faster than using the python `startswith` method + let name_cow = match name.cast_as::() { + Ok(name) => name.to_string_lossy(), + Err(e) => return Some(Err(e.into())), + }; + if !name_cow.as_ref().starts_with('_') { + // getattr is most likely to fail due to an exception in a @property, skip + if let Ok(attr) = self.object.getattr(name_cow.as_ref()) { + // we don't want bound methods to be included, is there a better way to check? + // ref https://stackoverflow.com/a/18955425/949890 + let is_bound = matches!(attr.hasattr(intern!(attr.py(), "__self__")), Ok(true)); + // the PyFunction::is_type_of(attr) catches `staticmethod`, but also any other function, + // I think that's better than including static methods in the yielded attributes, + // if someone really wants fields, they can use an explicit field, or a function to modify input + #[cfg(not(PyPy))] + if !is_bound && !PyFunction::is_type_of(attr) { + return Some(Ok((name, attr))); + } + // MASSIVE HACK! PyFunction doesn't exist for PyPy, + // is_instance_of:: crashes with a null pointer, hence this hack, see + // https://github.com/pydantic/pydantic-core/pull/161#discussion_r917257635 + #[cfg(PyPy)] + if !is_bound && attr.get_type().to_string() != "" { + return Some(Ok((name, attr))); + } + } + } + } + None + } + // size_hint is omitted as it isn't needed +} + +pub struct JsonObjectGenericIterator<'py> { + object_iter: Iter<'py, String, JsonInput>, +} + +impl<'py> JsonObjectGenericIterator<'py> { + pub fn new(json_object: &'py JsonObject) -> ValResult<'py, Self> { + Ok(Self { + object_iter: json_object.iter(), + }) + } +} + +impl<'py> Iterator for JsonObjectGenericIterator<'py> { + type Item = ValResult<'py, (&'py String, &'py JsonInput)>; + + fn next(&mut self) -> Option { + self.object_iter.next().map(Ok) + } + // size_hint is omitted as it isn't needed +} + #[derive(Debug, Clone)] pub enum GenericIterator { PyIterator(GenericPyIterator), diff --git a/src/lookup_key.rs b/src/lookup_key.rs index 15c0bc390..41b69321d 100644 --- a/src/lookup_key.rs +++ b/src/lookup_key.rs @@ -2,7 +2,7 @@ use std::fmt; use pyo3::exceptions::{PyAttributeError, PyTypeError}; use pyo3::prelude::*; -use pyo3::types::{PyDict, PyList, PyString}; +use pyo3::types::{PyDict, PyList, PyMapping, PyString}; use crate::build_tools::py_err; use crate::input::{JsonInput, JsonObject}; @@ -95,7 +95,7 @@ impl LookupKey { } } - pub fn py_get_item<'data, 's>(&'s self, dict: &'data PyDict) -> PyResult> { + pub fn py_get_dict_item<'data, 's>(&'s self, dict: &'data PyDict) -> PyResult> { match self { LookupKey::Simple(key, py_key) => match dict.get_item(py_key) { Some(value) => Ok(Some((key, value))), @@ -124,6 +124,38 @@ impl LookupKey { } } + pub fn py_get_mapping_item<'data, 's>( + &'s self, + dict: &'data PyMapping, + ) -> PyResult> { + match self { + LookupKey::Simple(key, py_key) => match dict.get_item(py_key) { + Ok(value) => Ok(Some((key, value))), + _ => Ok(None), + }, + LookupKey::Choice(key1, key2, py_key1, py_key2) => match dict.get_item(py_key1) { + Ok(value) => Ok(Some((key1, value))), + _ => match dict.get_item(py_key2) { + Ok(value) => Ok(Some((key2, value))), + _ => Ok(None), + }, + }, + LookupKey::PathChoices(path_choices) => { + for path in path_choices { + // iterate over the path and plug each value into the py_any from the last step, starting with dict + // this could just be a loop but should be somewhat faster with a functional design + if let Some(v) = path.iter().try_fold(dict as &PyAny, |d, loc| loc.py_get_item(d)) { + // Successfully found an item, return it + let key = path.first().unwrap().get_key(); + return Ok(Some((key, v))); + } + } + // got to the end of path_choices, without a match, return None + Ok(None) + } + } + } + pub fn py_get_attr<'data, 's>(&'s self, obj: &'data PyAny) -> PyResult> { match self { LookupKey::Simple(key, py_key) => match py_get_attrs(obj, py_key)? { diff --git a/src/validators/arguments.rs b/src/validators/arguments.rs index d5e8c8c63..e418b5507 100644 --- a/src/validators/arguments.rs +++ b/src/validators/arguments.rs @@ -304,7 +304,7 @@ impl Validator for ArgumentsValidator { }}; } match args { - GenericArguments::Py(a) => process!(a, py_get_item, py_get, py_slice), + GenericArguments::Py(a) => process!(a, py_get_dict_item, py_get, py_slice), GenericArguments::Json(a) => process!(a, json_get, json_get, json_slice), } if !errors.is_empty() { diff --git a/src/validators/dict.rs b/src/validators/dict.rs index 2af10a2d3..d093754d6 100644 --- a/src/validators/dict.rs +++ b/src/validators/dict.rs @@ -1,10 +1,12 @@ use pyo3::intern; use pyo3::prelude::*; -use pyo3::types::PyDict; +use pyo3::types::{PyDict, PyMapping}; use crate::build_tools::{is_strict, SchemaDict}; use crate::errors::{ValError, ValLineError, ValResult}; -use crate::input::{GenericMapping, Input, JsonObject}; +use crate::input::{ + DictGenericIterator, GenericMapping, Input, JsonObject, JsonObjectGenericIterator, MappingGenericIterator, +}; use crate::recursion_guard::RecursionGuard; use super::any::AnyValidator; @@ -68,6 +70,9 @@ impl Validator for DictValidator { let dict = input.validate_dict(extra.strict.unwrap_or(self.strict))?; match dict { GenericMapping::PyDict(py_dict) => self.validate_dict(py, input, py_dict, extra, slots, recursion_guard), + GenericMapping::PyMapping(mapping) => { + self.validate_mapping(py, input, mapping, extra, slots, recursion_guard) + } GenericMapping::PyGetAttr(_) => unreachable!(), GenericMapping::JsonObject(json_object) => { self.validate_json_object(py, input, json_object, extra, slots, recursion_guard) @@ -86,7 +91,7 @@ impl Validator for DictValidator { } macro_rules! build_validate { - ($name:ident, $dict_type:ty) => { + ($name:ident, $dict_type:ty, $iter:ty) => { fn $name<'s, 'data>( &'s self, py: Python<'data>, @@ -101,8 +106,8 @@ macro_rules! build_validate { let key_validator = self.key_validator.as_ref(); let value_validator = self.value_validator.as_ref(); - - for (key, value) in dict.iter() { + for item_result in <$iter>::new(dict)? { + let (key, value) = item_result?; let output_key = match key_validator.validate(py, key, extra, slots, recursion_guard) { Ok(value) => Some(value), Err(ValError::LineErrors(line_errors)) => { @@ -145,6 +150,7 @@ macro_rules! build_validate { } impl DictValidator { - build_validate!(validate_dict, PyDict); - build_validate!(validate_json_object, JsonObject); + build_validate!(validate_dict, PyDict, DictGenericIterator); + build_validate!(validate_mapping, PyMapping, MappingGenericIterator); + build_validate!(validate_json_object, JsonObject, JsonObjectGenericIterator); } diff --git a/src/validators/typed_dict.rs b/src/validators/typed_dict.rs index 0a84d456c..216d430c7 100644 --- a/src/validators/typed_dict.rs +++ b/src/validators/typed_dict.rs @@ -1,16 +1,15 @@ use pyo3::intern; use pyo3::prelude::*; -#[cfg(not(PyPy))] -use pyo3::types::PyFunction; -use pyo3::types::{PyDict, PyList, PySet, PyString}; -#[cfg(not(PyPy))] -use pyo3::PyTypeInfo; use ahash::AHashSet; +use pyo3::types::{PyDict, PySet, PyString}; use crate::build_tools::{is_strict, py_err, schema_or_config, schema_or_config_same, SchemaDict}; use crate::errors::{py_err_string, ErrorType, ValError, ValLineError, ValResult}; -use crate::input::{GenericMapping, Input}; +use crate::input::{ + AttributesGenericIterator, DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, + MappingGenericIterator, +}; use crate::lookup_key::LookupKey; use crate::questions::Question; use crate::recursion_guard::RecursionGuard; @@ -192,7 +191,7 @@ impl Validator for TypedDictValidator { }; macro_rules! process { - ($dict:ident, $get_method:ident, $iter_method:ident) => {{ + ($dict:ident, $get_method:ident, $iter:ty) => {{ for field in &self.fields { let op_key_value = match field.lookup_key.$get_method($dict) { Ok(v) => v, @@ -244,7 +243,8 @@ impl Validator for TypedDictValidator { } if let Some(ref mut used_keys) = used_keys { - for (raw_key, value) in $dict.$iter_method() { + for item_result in <$iter>::new($dict)? { + let (raw_key, value) = item_result?; let either_str = match raw_key.strict_str() { Ok(k) => k, Err(ValError::LineErrors(line_errors)) => { @@ -302,9 +302,10 @@ impl Validator for TypedDictValidator { }}; } match dict { - GenericMapping::PyDict(d) => process!(d, py_get_item, iter), - GenericMapping::PyGetAttr(d) => process!(d, py_get_attr, iter_attrs), - GenericMapping::JsonObject(d) => process!(d, json_get, iter), + GenericMapping::PyDict(d) => process!(d, py_get_dict_item, DictGenericIterator), + GenericMapping::PyMapping(d) => process!(d, py_get_mapping_item, MappingGenericIterator), + GenericMapping::PyGetAttr(d) => process!(d, py_get_attr, AttributesGenericIterator), + GenericMapping::JsonObject(d) => process!(d, json_get, JsonObjectGenericIterator), } if !errors.is_empty() { @@ -399,71 +400,3 @@ impl TypedDictValidator { } } } - -trait IterAttributes<'a> { - fn iter_attrs(&self) -> AttributesIterator<'a>; -} - -impl<'a> IterAttributes<'a> for &'a PyAny { - fn iter_attrs(&self) -> AttributesIterator<'a> { - AttributesIterator { - object: self, - attributes: self.dir(), - index: 0, - } - } -} - -struct AttributesIterator<'a> { - object: &'a PyAny, - attributes: &'a PyList, - index: usize, -} - -impl<'a> Iterator for AttributesIterator<'a> { - type Item = (&'a PyAny, &'a PyAny); - - fn next(&mut self) -> Option<(&'a PyAny, &'a PyAny)> { - // loop until we find an attribute who's name does not start with underscore, - // or we get to the end of the list of attributes - loop { - if self.index < self.attributes.len() { - #[cfg(PyPy)] - let name: &PyAny = self.attributes.get_item(self.index).unwrap(); - #[cfg(not(PyPy))] - let name: &PyAny = unsafe { self.attributes.get_item_unchecked(self.index) }; - self.index += 1; - // from benchmarks this is 14x faster than using the python `startswith` method - let name_cow = name - .cast_as::() - .expect("dir didn't return a PyString") - .to_string_lossy(); - if !name_cow.as_ref().starts_with('_') { - // getattr is most likely to fail due to an exception in a @property, skip - if let Ok(attr) = self.object.getattr(name_cow.as_ref()) { - // we don't want bound methods to be included, is there a better way to check? - // ref https://stackoverflow.com/a/18955425/949890 - let is_bound = matches!(attr.hasattr(intern!(attr.py(), "__self__")), Ok(true)); - // the PyFunction::is_type_of(attr) catches `staticmethod`, but also any other function, - // I think that's better than including static methods in the yielded attributes, - // if someone really wants fields, they can use an explicit field, or a function to modify input - #[cfg(not(PyPy))] - if !is_bound && !PyFunction::is_type_of(attr) { - return Some((name, attr)); - } - // MASSIVE HACK! PyFunction doesn't exist for PyPy, - // is_instance_of:: crashes with a null pointer, hence this hack, see - // https://github.com/pydantic/pydantic-core/pull/161#discussion_r917257635 - #[cfg(PyPy)] - if !is_bound && attr.get_type().to_string() != "" { - return Some((name, attr)); - } - } - } - } else { - return None; - } - } - } - // size_hint is omitted as it isn't needed -} diff --git a/src/validators/union.rs b/src/validators/union.rs index e01811ed9..df0cf09e7 100644 --- a/src/validators/union.rs +++ b/src/validators/union.rs @@ -273,8 +273,9 @@ impl Validator for TaggedUnionValidator { } let dict = input.validate_typed_dict(self.strict, self.from_attributes)?; let tag = match dict { - GenericMapping::PyDict(dict) => find_validator!(dict, py_get_item), + GenericMapping::PyDict(dict) => find_validator!(dict, py_get_dict_item), GenericMapping::PyGetAttr(obj) => find_validator!(obj, py_get_attr), + GenericMapping::PyMapping(mapping) => find_validator!(mapping, py_get_mapping_item), GenericMapping::JsonObject(mapping) => find_validator!(mapping, json_get), }?; self.find_call_validator(py, tag.as_cow()?, input, extra, slots, recursion_guard) diff --git a/tests/test_errors.py b/tests/test_errors.py index b8a038850..807d252fd 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -197,7 +197,7 @@ def f(input_value, **kwargs): ('string_too_short', 'String should have at least 42 characters', {'min_length': 42}), ('string_too_long', 'String should have at most 42 characters', {'max_length': 42}), ('dict_type', 'Input should be a valid dictionary', None), - ('dict_from_mapping', 'Unable to convert mapping to a dictionary, error: foobar', {'error': 'foobar'}), + ('mapping_type', 'Input should be a valid mapping, error: foobar', {'error': 'foobar'}), ('iterable_type', 'Input should be iterable', None), ('iteration_error', 'Error iterating over object, error: foobar', {'error': 'foobar'}), ('list_type', 'Input should be a valid list/array', None), diff --git a/tests/validators/test_dict.py b/tests/validators/test_dict.py index 718e3c1b7..c60f37bc2 100644 --- a/tests/validators/test_dict.py +++ b/tests/validators/test_dict.py @@ -150,25 +150,26 @@ def __len__(self): assert exc_info.value.errors() == [ { - 'type': 'dict_from_mapping', + 'type': 'mapping_type', 'loc': (), - 'msg': 'Unable to convert mapping to a dictionary, error: RuntimeError: intentional error', + 'msg': 'Input should be a valid mapping, error: RuntimeError: intentional error', 'input': HasRepr(IsStr(regex='.+BadMapping object at.+')), 'ctx': {'error': 'RuntimeError: intentional error'}, } ] -def test_mapping_error_yield_1(): +@pytest.mark.parametrize('mapping_items', [[(1,)], ['foobar'], [(1, 2, 3)], 'not list']) +def test_mapping_error_yield_1(mapping_items): class BadMapping(Mapping): def items(self): - return [(1,)] + return mapping_items def __iter__(self): - return iter({1: 2}) + pytest.fail('unexpected call to __iter__') def __getitem__(self, key): - raise None + pytest.fail('unexpected call to __getitem__') def __len__(self): return 1 @@ -179,14 +180,11 @@ def __len__(self): assert exc_info.value.errors() == [ { - 'type': 'dict_from_mapping', + 'type': 'mapping_type', 'loc': (), - 'msg': ( - 'Unable to convert mapping to a dictionary, error: ' - 'ValueError: expected tuple of length 2, but got tuple of length 1' - ), + 'msg': 'Input should be a valid mapping, error: Mapping items must be tuples of (key, value) pairs', 'input': HasRepr(IsStr(regex='.+BadMapping object at.+')), - 'ctx': {'error': 'ValueError: expected tuple of length 2, but got tuple of length 1'}, + 'ctx': {'error': 'Mapping items must be tuples of (key, value) pairs'}, } ]