diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index 9b24e7076..be2b64793 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -52,6 +52,8 @@ _recursion_limit: int _T = TypeVar('_T', default=Any, covariant=True) +_StringInput: TypeAlias = 'dict[str, _StringInput]' + @final class Some(Generic[_T]): """ @@ -168,6 +170,29 @@ class SchemaValidator: ValidationError: If validation fails or if the JSON data is invalid. Exception: Other error types maybe raised if internal errors occur. + Returns: + The validated Python object. + """ + def validate_strings( + self, input: _StringInput, *, strict: bool | None = None, context: 'dict[str, Any] | None' = None + ) -> Any: + """ + Validate a string against the schema and return the validated Python object. + + This is similar to `validate_json` but applies to scenarios where the input will be a string but not + JSON data, e.g. URL fragments, query parameters, etc. + + Arguments: + input: The input as a string, or bytes/bytearray if `strict=False`. + strict: Whether to validate the object in strict mode. + If `None`, the value of [`CoreConfig.strict`][pydantic_core.core_schema.CoreConfig] is used. + context: The context to use for validation, this is passed to functional validators as + [`info.context`][pydantic_core.core_schema.ValidationInfo.context]. + + Raises: + ValidationError: If validation fails or if the JSON data is invalid. + Exception: Other error types maybe raised if internal errors occur. + Returns: The validated Python object. """ @@ -680,7 +705,7 @@ class ValidationError(ValueError): def from_exception_data( title: str, line_errors: list[InitErrorDetails], - error_mode: Literal['python', 'json'] = 'python', + input_type: Literal['python', 'json'] = 'python', hide_input: bool = False, ) -> ValidationError: """ @@ -693,7 +718,7 @@ class ValidationError(ValueError): title: The title of the error, as used in the heading of `str(validation_error)` line_errors: A list of [`InitErrorDetails`][pydantic_core.InitErrorDetails] which contain information about errors that occurred during validation. - error_mode: Whether the error is for a Python object or JSON. + input_type: Whether the error is for a Python object or JSON. hide_input: Whether to hide the input value in the error message. """ @property diff --git a/src/build_tools.rs b/src/build_tools.rs index d2bc7c2cd..47fa569ac 100644 --- a/src/build_tools.rs +++ b/src/build_tools.rs @@ -6,7 +6,8 @@ use pyo3::prelude::*; use pyo3::types::{PyDict, PyList, PyString}; use pyo3::{intern, FromPyObject, PyErrArguments}; -use crate::errors::{ErrorMode, ValError}; +use crate::errors::ValError; +use crate::input::InputType; use crate::tools::SchemaDict; use crate::ValidationError; @@ -86,7 +87,7 @@ impl SchemaError { ValError::LineErrors(raw_errors) => { let line_errors = raw_errors.into_iter().map(|e| e.into_py(py)).collect(); let validation_error = - ValidationError::new(line_errors, "Schema".to_object(py), ErrorMode::Python, false); + ValidationError::new(line_errors, "Schema".to_object(py), InputType::Python, false); let schema_error = SchemaError(SchemaErrorEnum::ValidationError(validation_error)); match Py::new(py, schema_error) { Ok(err) => PyErr::from_value(err.into_ref(py)), diff --git a/src/errors/mod.rs b/src/errors/mod.rs index ed10049de..6a253197f 100644 --- a/src/errors/mod.rs +++ b/src/errors/mod.rs @@ -8,7 +8,7 @@ mod value_exception; pub use self::line_error::{InputValue, ValError, ValLineError, ValResult}; pub use self::location::LocItem; -pub use self::types::{list_all_errors, ErrorMode, ErrorType, ErrorTypeDefaults, Number}; +pub use self::types::{list_all_errors, ErrorType, ErrorTypeDefaults, Number}; pub use self::validation_exception::ValidationError; pub use self::value_exception::{PydanticCustomError, PydanticKnownError, PydanticOmit, PydanticUseDefault}; diff --git a/src/errors/types.rs b/src/errors/types.rs index d7e1051b7..da4d5fdd7 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -2,37 +2,20 @@ use std::any::type_name; use std::borrow::Cow; use std::fmt; -use ahash::AHashMap; -use num_bigint::BigInt; -use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError}; +use pyo3::exceptions::{PyKeyError, PyTypeError}; use pyo3::once_cell::GILOnceCell; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; -use crate::input::Int; -use crate::tools::{extract_i64, py_err, py_error_type}; +use ahash::AHashMap; +use num_bigint::BigInt; use strum::{Display, EnumMessage, IntoEnumIterator}; use strum_macros::EnumIter; -use super::PydanticCustomError; - -#[derive(Clone, Debug)] -pub enum ErrorMode { - Python, - Json, -} - -impl TryFrom<&str> for ErrorMode { - type Error = PyErr; +use crate::input::{InputType, Int}; +use crate::tools::{extract_i64, py_err, py_error_type}; - fn try_from(error_mode: &str) -> PyResult { - match error_mode { - "python" => Ok(Self::Python), - "json" => Ok(Self::Json), - s => py_err!(PyValueError; "Invalid error mode: {}", s), - } - } -} +use super::PydanticCustomError; #[pyfunction] pub fn list_all_errors(py: Python) -> PyResult<&PyList> { @@ -45,12 +28,12 @@ pub fn list_all_errors(py: Python) -> PyResult<&PyList> { d.set_item("message_template_python", message_template_python)?; d.set_item( "example_message_python", - error_type.render_message(py, &ErrorMode::Python)?, + error_type.render_message(py, InputType::Python)?, )?; let message_template_json = error_type.message_template_json(); if message_template_python != message_template_json { d.set_item("message_template_json", message_template_json)?; - d.set_item("example_message_json", error_type.render_message(py, &ErrorMode::Json)?)?; + d.set_item("example_message_json", error_type.render_message(py, InputType::Json)?)?; } d.set_item("example_context", error_type.py_dict(py)?)?; errors.push(d); @@ -623,10 +606,10 @@ impl ErrorType { } } - pub fn render_message(&self, py: Python, error_mode: &ErrorMode) -> PyResult { - let tmpl = match error_mode { - ErrorMode::Python => self.message_template_python(), - ErrorMode::Json => self.message_template_json(), + pub fn render_message(&self, py: Python, input_type: InputType) -> PyResult { + let tmpl = match input_type { + InputType::Python => self.message_template_python(), + _ => self.message_template_json(), }; match self { Self::NoSuchAttribute { attribute, .. } => render!(tmpl, attribute), diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index e6563f597..d0977001a 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -16,12 +16,13 @@ use serde_json::ser::PrettyFormatter; use crate::build_tools::py_schema_error_type; use crate::errors::LocItem; use crate::get_pydantic_version; +use crate::input::InputType; use crate::serializers::{SerMode, SerializationState}; use crate::tools::{safe_repr, SchemaDict}; use super::line_error::ValLineError; use super::location::Location; -use super::types::{ErrorMode, ErrorType}; +use super::types::ErrorType; use super::value_exception::PydanticCustomError; use super::{InputValue, ValError}; @@ -31,16 +32,16 @@ use super::{InputValue, ValError}; pub struct ValidationError { line_errors: Vec, title: PyObject, - error_mode: ErrorMode, + input_type: InputType, hide_input: bool, } impl ValidationError { - pub fn new(line_errors: Vec, title: PyObject, error_mode: ErrorMode, hide_input: bool) -> Self { + pub fn new(line_errors: Vec, title: PyObject, input_type: InputType, hide_input: bool) -> Self { Self { line_errors, title, - error_mode, + input_type, hide_input, } } @@ -48,7 +49,7 @@ impl ValidationError { pub fn from_val_error( py: Python, title: PyObject, - error_mode: ErrorMode, + input_type: InputType, error: ValError, outer_location: Option, hide_input: bool, @@ -63,9 +64,7 @@ impl ValidationError { .collect(), None => raw_errors.into_iter().map(|e| e.into_py(py)).collect(), }; - - let validation_error = Self::new(line_errors, title, error_mode, hide_input); - + let validation_error = Self::new(line_errors, title, input_type, hide_input); match Py::new(py, validation_error) { Ok(err) => { if validation_error_cause { @@ -87,7 +86,7 @@ impl ValidationError { pub fn display(&self, py: Python, prefix_override: Option<&'static str>, hide_input: bool) -> String { let url_prefix = get_url_prefix(py, include_url_env(py)); - let line_errors = pretty_py_line_errors(py, &self.error_mode, self.line_errors.iter(), url_prefix, hide_input); + let line_errors = pretty_py_line_errors(py, self.input_type, self.line_errors.iter(), url_prefix, hide_input); if let Some(prefix) = prefix_override { format!("{prefix}\n{line_errors}") } else { @@ -238,12 +237,12 @@ impl ValidationError { #[pymethods] impl ValidationError { #[staticmethod] - #[pyo3(signature = (title, line_errors, error_mode="python", hide_input=false))] + #[pyo3(signature = (title, line_errors, input_type="python", hide_input=false))] fn from_exception_data( py: Python, title: PyObject, line_errors: &PyList, - error_mode: &str, + input_type: &str, hide_input: bool, ) -> PyResult> { Py::new( @@ -251,7 +250,7 @@ impl ValidationError { Self { line_errors: line_errors.iter().map(PyLineError::try_from).collect::>()?, title, - error_mode: ErrorMode::try_from(error_mode)?, + input_type: InputType::try_from(input_type)?, hide_input, }, ) @@ -279,7 +278,7 @@ impl ValidationError { if iteration_error.is_some() { return py.None(); } - e.as_dict(py, url_prefix, include_context, &self.error_mode) + e.as_dict(py, url_prefix, include_context, self.input_type) .unwrap_or_else(|err| { iteration_error = Some(err); py.None() @@ -309,7 +308,7 @@ impl ValidationError { url_prefix: get_url_prefix(py, include_url), include_context, extra: &extra, - error_mode: &self.error_mode, + input_type: &self.input_type, }; let writer: Vec = Vec::with_capacity(self.line_errors.len() * 200); @@ -387,13 +386,13 @@ macro_rules! truncate_input_value { pub fn pretty_py_line_errors<'a>( py: Python, - error_mode: &ErrorMode, + input_type: InputType, line_errors_iter: impl Iterator, url_prefix: Option<&str>, hide_input: bool, ) -> String { line_errors_iter - .map(|i| i.pretty(py, error_mode, url_prefix, hide_input)) + .map(|i| i.pretty(py, input_type, url_prefix, hide_input)) .collect::, _>>() .unwrap_or_else(|err| vec![format!("[error formatting line errors: {err}]")]) .join("\n") @@ -477,12 +476,12 @@ impl PyLineError { py: Python, url_prefix: Option<&str>, include_context: bool, - error_mode: &ErrorMode, + input_type: InputType, ) -> PyResult { let dict = PyDict::new(py); dict.set_item("type", self.error_type.type_string())?; dict.set_item("loc", self.location.to_object(py))?; - dict.set_item("msg", self.error_type.render_message(py, error_mode)?)?; + dict.set_item("msg", self.error_type.render_message(py, input_type)?)?; dict.set_item("input", &self.input_value)?; if include_context { if let Some(context) = self.error_type.py_dict(py)? { @@ -505,14 +504,14 @@ impl PyLineError { fn pretty( &self, py: Python, - error_mode: &ErrorMode, + input_type: InputType, url_prefix: Option<&str>, hide_input: bool, ) -> Result { let mut output = String::with_capacity(200); write!(output, "{}", self.location)?; - let message = match self.error_type.render_message(py, error_mode) { + let message = match self.error_type.render_message(py, input_type) { Ok(message) => message, Err(err) => format!("(error rendering message: {err})"), }; @@ -565,7 +564,7 @@ struct ValidationErrorSerializer<'py> { url_prefix: Option<&'py str>, include_context: bool, extra: &'py crate::serializers::Extra<'py>, - error_mode: &'py ErrorMode, + input_type: &'py InputType, } impl<'py> Serialize for ValidationErrorSerializer<'py> { @@ -581,7 +580,7 @@ impl<'py> Serialize for ValidationErrorSerializer<'py> { url_prefix: self.url_prefix, include_context: self.include_context, extra: self.extra, - error_mode: self.error_mode, + input_type: self.input_type, }; seq.serialize_element(&line_s)?; } @@ -595,7 +594,7 @@ struct PyLineErrorSerializer<'py> { url_prefix: Option<&'py str>, include_context: bool, extra: &'py crate::serializers::Extra<'py>, - error_mode: &'py ErrorMode, + input_type: &'py InputType, } impl<'py> Serialize for PyLineErrorSerializer<'py> { @@ -620,7 +619,7 @@ impl<'py> Serialize for PyLineErrorSerializer<'py> { let msg = self .line_error .error_type - .render_message(py, self.error_mode) + .render_message(py, *self.input_type) .map_err(py_err_json::)?; map.serialize_entry("msg", &msg)?; diff --git a/src/errors/value_exception.rs b/src/errors/value_exception.rs index d0c08bf5f..f7d877b30 100644 --- a/src/errors/value_exception.rs +++ b/src/errors/value_exception.rs @@ -1,9 +1,8 @@ -use crate::errors::ErrorMode; use pyo3::exceptions::{PyException, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyString}; -use crate::input::Input; +use crate::input::{Input, InputType}; use crate::tools::extract_i64; use super::{ErrorType, ValError}; @@ -164,7 +163,7 @@ impl PydanticKnownError { } pub fn message(&self, py: Python) -> PyResult { - self.error_type.render_message(py, &ErrorMode::Python) + self.error_type.render_message(py, InputType::Python) } fn __str__(&self, py: Python) -> PyResult { diff --git a/src/input/input_abstract.rs b/src/input/input_abstract.rs index d799da473..f4a760a45 100644 --- a/src/input/input_abstract.rs +++ b/src/input/input_abstract.rs @@ -1,9 +1,11 @@ use std::fmt; +use pyo3::exceptions::PyValueError; use pyo3::types::{PyDict, PyType}; use pyo3::{intern, prelude::*}; use crate::errors::{InputValue, LocItem, ValResult}; +use crate::tools::py_err; use crate::{PyMultiHostUrl, PyUrl}; use super::datetime::{EitherDate, EitherDateTime, EitherTime, EitherTimedelta}; @@ -14,6 +16,7 @@ use super::{EitherFloat, GenericArguments, GenericIterable, GenericIterator, Gen pub enum InputType { Python, Json, + String, } impl IntoPy for InputType { @@ -21,6 +24,20 @@ impl IntoPy for InputType { match self { Self::Json => intern!(py, "json").into(), Self::Python => intern!(py, "python").into(), + Self::String => intern!(py, "string").into(), + } + } +} + +impl TryFrom<&str> for InputType { + type Error = PyErr; + + fn try_from(error_mode: &str) -> PyResult { + match error_mode { + "python" => Ok(Self::Python), + "json" => Ok(Self::Json), + "string" => Ok(Self::String), + s => py_err!(PyValueError; "Invalid error mode: {}", s), } } } @@ -38,7 +55,9 @@ pub trait Input<'a>: fmt::Debug + ToPyObject { None } - fn is_none(&self) -> bool; + fn is_none(&self) -> bool { + false + } fn input_is_instance(&self, _class: &PyType) -> Option<&PyAny> { None @@ -320,3 +339,19 @@ pub trait Input<'a>: fmt::Debug + ToPyObject { self.strict_timedelta(microseconds_overflow_behavior) } } + +/// The problem to solve here is that iterating a `StringMapping` returns an owned +/// `StringMapping`, but all the other iterators return references. By introducing +/// this trait we abstract over whether the return value from the iterator is owned +/// or borrowed; all we care about is that we can borrow it again with `borrow_input` +/// for some lifetime 'a. +/// +/// This lifetime `'a` is shorter than the original lifetime `'data` of the input, +/// which is only a problem in error branches. To resolve we have to call `into_owned` +/// to extend out the lifetime to match the original input. +pub trait BorrowInput { + type Input<'a>: Input<'a> + where + Self: 'a; + fn borrow_input(&self) -> &Self::Input<'_>; +} diff --git a/src/input/input_json.rs b/src/input/input_json.rs index d948e2493..07f3554e6 100644 --- a/src/input/input_json.rs +++ b/src/input/input_json.rs @@ -12,11 +12,10 @@ use super::datetime::{ bytes_as_date, bytes_as_datetime, bytes_as_time, bytes_as_timedelta, float_as_datetime, float_as_duration, float_as_time, int_as_datetime, int_as_duration, int_as_time, EitherDate, EitherDateTime, EitherTime, }; -use super::parse_json::JsonArray; -use super::shared::{float_as_int, int_as_bool, map_json_err, str_as_bool, str_as_float, str_as_int}; +use super::shared::{float_as_int, int_as_bool, map_json_err, str_as_bool, str_as_float, str_as_int, string_to_vec}; use super::{ - EitherBytes, EitherFloat, EitherInt, EitherString, EitherTimedelta, GenericArguments, GenericIterable, - GenericIterator, GenericMapping, Input, JsonArgs, JsonInput, + BorrowInput, EitherBytes, EitherFloat, EitherInt, EitherString, EitherTimedelta, GenericArguments, GenericIterable, + GenericIterator, GenericMapping, Input, JsonArgs, JsonArray, JsonInput, }; impl<'a> Input<'a> for JsonInput { @@ -355,6 +354,15 @@ impl<'a> Input<'a> for JsonInput { } } +impl BorrowInput for &'_ JsonInput { + type Input<'a> = JsonInput where Self: 'a; + fn borrow_input(&self) -> &Self::Input<'_> { + self + } +} + +/// TODO: it would be good to get JsonInput and StringMapping string variants to go through this +/// implementation /// Required for Dict keys so the string can behave like an Input impl<'a> Input<'a> for String { fn as_loc_item(&self) -> LocItem { @@ -365,11 +373,6 @@ impl<'a> Input<'a> for String { InputValue::String(self) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn is_none(&self) -> bool { - false - } - fn as_kwargs(&'a self, _py: Python<'a>) -> Option<&'a PyDict> { None } @@ -395,47 +398,29 @@ impl<'a> Input<'a> for String { serde_json::from_str(self.as_str()).map_err(|e| map_json_err(self, e)) } - fn validate_str(&'a self, _strict: bool) -> ValResult> { - Ok(self.as_str().into()) - } fn strict_str(&'a self) -> ValResult> { - self.validate_str(false) + Ok(self.as_str().into()) } - fn validate_bytes(&'a self, _strict: bool) -> ValResult> { - Ok(self.as_bytes().into()) - } - #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_bytes(&'a self) -> ValResult> { - self.validate_bytes(false) + Ok(self.as_bytes().into()) } fn strict_bool(&self) -> ValResult { - Err(ValError::new(ErrorTypeDefaults::BoolType, self)) - } - fn lax_bool(&self) -> ValResult { str_as_bool(self, self) } fn strict_int(&'a self) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::IntType, self)) - } - fn lax_int(&'a self) -> ValResult> { match self.parse() { Ok(i) => Ok(EitherInt::I64(i)), Err(_) => Err(ValError::new(ErrorTypeDefaults::IntParsing, self)), } } - #[cfg_attr(has_coverage_attribute, coverage(off))] fn ultra_strict_float(&'a self) -> ValResult> { self.strict_float() } - #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_float(&'a self) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::FloatType, self)) - } - fn lax_float(&'a self) -> ValResult> { str_as_float(self, self) } @@ -443,49 +428,29 @@ impl<'a> Input<'a> for String { create_decimal(self.to_object(py).into_ref(py), self, py) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn validate_dict(&'a self, _strict: bool) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::DictType, self)) - } #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_dict(&'a self) -> ValResult> { - self.validate_dict(false) + Err(ValError::new(ErrorTypeDefaults::DictType, self)) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn validate_list(&'a self, _strict: bool) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::ListType, self)) - } #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_list(&'a self) -> ValResult> { - self.validate_list(false) + Err(ValError::new(ErrorTypeDefaults::ListType, self)) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn validate_tuple(&'a self, _strict: bool) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::TupleType, self)) - } #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_tuple(&'a self) -> ValResult> { - self.validate_tuple(false) + Err(ValError::new(ErrorTypeDefaults::TupleType, self)) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn validate_set(&'a self, _strict: bool) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::SetType, self)) - } #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_set(&'a self) -> ValResult> { - self.validate_set(false) + Err(ValError::new(ErrorTypeDefaults::SetType, self)) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn validate_frozenset(&'a self, _strict: bool) -> ValResult> { - Err(ValError::new(ErrorTypeDefaults::FrozenSetType, self)) - } #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_frozenset(&'a self) -> ValResult> { - self.validate_frozenset(false) + Err(ValError::new(ErrorTypeDefaults::FrozenSetType, self)) } fn extract_generic_iterable(&'a self) -> ValResult> { @@ -496,60 +461,42 @@ impl<'a> Input<'a> for String { Ok(string_to_vec(self).into()) } - fn validate_date(&self, _strict: bool) -> ValResult { - bytes_as_date(self, self.as_bytes()) - } - #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_date(&self) -> ValResult { - self.validate_date(false) + bytes_as_date(self, self.as_bytes()) } - fn validate_time( - &self, - _strict: bool, - microseconds_overflow_behavior: speedate::MicrosecondsPrecisionOverflowBehavior, - ) -> ValResult { - bytes_as_time(self, self.as_bytes(), microseconds_overflow_behavior) - } - #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_time( &self, - microseconds_overflow_behavior: speedate::MicrosecondsPrecisionOverflowBehavior, + microseconds_overflow_behavior: MicrosecondsPrecisionOverflowBehavior, ) -> ValResult { - self.validate_time(false, microseconds_overflow_behavior) + bytes_as_time(self, self.as_bytes(), microseconds_overflow_behavior) } - fn validate_datetime( - &self, - _strict: bool, - microseconds_overflow_behavior: speedate::MicrosecondsPrecisionOverflowBehavior, - ) -> ValResult { - bytes_as_datetime(self, self.as_bytes(), microseconds_overflow_behavior) - } - #[cfg_attr(has_coverage_attribute, coverage(off))] fn strict_datetime( &self, - microseconds_overflow_behavior: speedate::MicrosecondsPrecisionOverflowBehavior, + microseconds_overflow_behavior: MicrosecondsPrecisionOverflowBehavior, ) -> ValResult { - self.validate_datetime(false, microseconds_overflow_behavior) + bytes_as_datetime(self, self.as_bytes(), microseconds_overflow_behavior) } - fn validate_timedelta( + fn strict_timedelta( &self, - _strict: bool, - microseconds_overflow_behavior: speedate::MicrosecondsPrecisionOverflowBehavior, + microseconds_overflow_behavior: MicrosecondsPrecisionOverflowBehavior, ) -> ValResult { bytes_as_timedelta(self, self.as_bytes(), microseconds_overflow_behavior) } - #[cfg_attr(has_coverage_attribute, coverage(off))] - fn strict_timedelta( - &self, - microseconds_overflow_behavior: speedate::MicrosecondsPrecisionOverflowBehavior, - ) -> ValResult { - self.validate_timedelta(false, microseconds_overflow_behavior) +} + +impl BorrowInput for &'_ String { + type Input<'a> = String where Self: 'a; + fn borrow_input(&self) -> &Self::Input<'_> { + self } } -fn string_to_vec(s: &str) -> JsonArray { - JsonArray::new(s.chars().map(|c| JsonInput::String(c.to_string())).collect()) +impl BorrowInput for String { + type Input<'a> = String where Self: 'a; + fn borrow_input(&self) -> &Self::Input<'_> { + self + } } diff --git a/src/input/input_python.rs b/src/input/input_python.rs index 3fbf20240..9e5fd1d1f 100644 --- a/src/input/input_python.rs +++ b/src/input/input_python.rs @@ -23,7 +23,7 @@ use super::datetime::{ }; use super::shared::{decimal_as_int, float_as_int, int_as_bool, map_json_err, str_as_bool, str_as_float, str_as_int}; use super::{ - py_string_str, EitherBytes, EitherFloat, EitherInt, EitherString, EitherTimedelta, GenericArguments, + py_string_str, BorrowInput, EitherBytes, EitherFloat, EitherInt, EitherString, EitherTimedelta, GenericArguments, GenericIterable, GenericIterator, GenericMapping, Input, JsonInput, PyArgs, }; @@ -184,7 +184,7 @@ impl<'a> Input<'a> for PyAny { if let Ok(py_bytes) = self.downcast::() { serde_json::from_slice(py_bytes.as_bytes()).map_err(|e| map_json_err(self, e)) } else if let Ok(py_str) = self.downcast::() { - let str = py_str.to_str()?; + let str = py_string_str(py_str)?; serde_json::from_str(str).map_err(|e| map_json_err(self, e)) } else if let Ok(py_byte_array) = self.downcast::() { // Safety: from_slice does not run arbitrary Python code and the GIL is held so the @@ -196,7 +196,7 @@ impl<'a> Input<'a> for PyAny { } fn strict_str(&'a self) -> ValResult> { - if let Ok(py_str) = ::try_from_exact(self) { + if let Ok(py_str) = PyString::try_from_exact(self) { Ok(py_str.into()) } else if let Ok(py_str) = self.downcast::() { // force to a rust string to make sure behavior is consistent whether or not we go via a @@ -208,7 +208,7 @@ impl<'a> Input<'a> for PyAny { } fn exact_str(&'a self) -> ValResult> { - if let Ok(py_str) = ::try_from_exact(self) { + if let Ok(py_str) = PyString::try_from_exact(self) { Ok(EitherString::Py(py_str)) } else { Err(ValError::new(ErrorTypeDefaults::IntType, self)) @@ -710,6 +710,13 @@ impl<'a> Input<'a> for PyAny { } } +impl BorrowInput for &'_ PyAny { + type Input<'a> = PyAny where Self: 'a; + fn borrow_input(&self) -> &Self::Input<'_> { + self + } +} + /// 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/input_string.rs b/src/input/input_string.rs new file mode 100644 index 000000000..72a32d897 --- /dev/null +++ b/src/input/input_string.rs @@ -0,0 +1,229 @@ +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyString}; + +use speedate::MicrosecondsPrecisionOverflowBehavior; + +use crate::errors::{ErrorTypeDefaults, InputValue, LocItem, ValError, ValResult}; +use crate::input::py_string_str; +use crate::tools::safe_repr; +use crate::validators::decimal::create_decimal; + +use super::datetime::{ + bytes_as_date, bytes_as_datetime, bytes_as_time, bytes_as_timedelta, EitherDate, EitherDateTime, EitherTime, +}; +use super::shared::{map_json_err, str_as_bool, str_as_float}; +use super::{ + BorrowInput, EitherBytes, EitherFloat, EitherInt, EitherString, EitherTimedelta, GenericArguments, GenericIterable, + GenericIterator, GenericMapping, Input, JsonInput, +}; + +#[derive(Debug)] +pub enum StringMapping<'py> { + String(&'py PyString), + Mapping(&'py PyDict), +} + +impl<'py> ToPyObject for StringMapping<'py> { + fn to_object(&self, py: Python<'_>) -> PyObject { + match self { + Self::String(s) => s.to_object(py), + Self::Mapping(d) => d.to_object(py), + } + } +} + +impl<'py> StringMapping<'py> { + pub fn new_key(py_key: &'py PyAny) -> ValResult<'py, StringMapping> { + if let Ok(py_str) = py_key.downcast::() { + Ok(Self::String(py_str)) + } else { + Err(ValError::new(ErrorTypeDefaults::StringType, py_key)) + } + } + + pub fn new_value(py_value: &'py PyAny) -> ValResult<'py, Self> { + if let Ok(py_str) = py_value.downcast::() { + Ok(Self::String(py_str)) + } else if let Ok(value) = py_value.downcast::() { + Ok(Self::Mapping(value)) + } else { + Err(ValError::new(ErrorTypeDefaults::StringType, py_value)) + } + } +} + +impl<'a> Input<'a> for StringMapping<'a> { + fn as_loc_item(&self) -> LocItem { + match self { + Self::String(s) => s.to_string_lossy().as_ref().into(), + Self::Mapping(d) => safe_repr(d).to_string().into(), + } + } + + fn as_error_value(&'a self) -> InputValue<'a> { + match self { + Self::String(s) => s.as_error_value(), + Self::Mapping(d) => InputValue::PyAny(d), + } + } + + fn as_kwargs(&'a self, _py: Python<'a>) -> Option<&'a PyDict> { + None + } + + fn validate_args(&'a self) -> ValResult<'a, GenericArguments<'a>> { + // do we want to support this? + Err(ValError::new(ErrorTypeDefaults::ArgumentsType, self)) + } + + fn validate_dataclass_args(&'a self, _dataclass_name: &str) -> ValResult<'a, GenericArguments<'a>> { + match self { + StringMapping::String(_) => Err(ValError::new(ErrorTypeDefaults::ArgumentsType, self)), + StringMapping::Mapping(m) => Ok(GenericArguments::StringMapping(m)), + } + } + + fn parse_json(&'a self) -> ValResult<'a, JsonInput> { + match self { + Self::String(s) => { + let str = py_string_str(s)?; + serde_json::from_str(str).map_err(|e| map_json_err(self, e)) + } + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::JsonType, self)), + } + } + + fn strict_str(&'a self) -> ValResult> { + match self { + Self::String(s) => Ok((*s).into()), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::StringType, self)), + } + } + + fn strict_bytes(&'a self) -> ValResult> { + match self { + Self::String(s) => py_string_str(s).map(|b| b.as_bytes().into()), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::BytesType, self)), + } + } + + fn lax_bytes(&'a self) -> ValResult> { + match self { + Self::String(s) => { + let str = py_string_str(s)?; + Ok(str.as_bytes().into()) + } + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::BytesType, self)), + } + } + + fn strict_bool(&self) -> ValResult { + match self { + Self::String(s) => str_as_bool(self, py_string_str(s)?), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::BoolType, self)), + } + } + + fn strict_int(&'a self) -> ValResult> { + match self { + Self::String(s) => match py_string_str(s)?.parse() { + Ok(i) => Ok(EitherInt::I64(i)), + Err(_) => Err(ValError::new(ErrorTypeDefaults::IntParsing, self)), + }, + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::IntType, self)), + } + } + + fn ultra_strict_float(&'a self) -> ValResult> { + self.strict_float() + } + + fn strict_float(&'a self) -> ValResult> { + match self { + Self::String(s) => str_as_float(self, py_string_str(s)?), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::FloatType, self)), + } + } + + fn strict_decimal(&'a self, py: Python<'a>) -> ValResult<&'a PyAny> { + match self { + Self::String(s) => create_decimal(s, self, py), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::DecimalType, self)), + } + } + + fn strict_dict(&'a self) -> ValResult> { + match self { + Self::String(_) => Err(ValError::new(ErrorTypeDefaults::DictType, self)), + Self::Mapping(d) => Ok(GenericMapping::StringMapping(d)), + } + } + + fn strict_list(&'a self) -> ValResult> { + Err(ValError::new(ErrorTypeDefaults::ListType, self)) + } + + fn strict_tuple(&'a self) -> ValResult> { + Err(ValError::new(ErrorTypeDefaults::TupleType, self)) + } + + fn strict_set(&'a self) -> ValResult> { + Err(ValError::new(ErrorTypeDefaults::SetType, self)) + } + + fn strict_frozenset(&'a self) -> ValResult> { + Err(ValError::new(ErrorTypeDefaults::FrozenSetType, self)) + } + + fn extract_generic_iterable(&'a self) -> ValResult> { + Err(ValError::new(ErrorTypeDefaults::IterableType, self)) + } + + fn validate_iter(&self) -> ValResult { + Err(ValError::new(ErrorTypeDefaults::IterableType, self)) + } + + fn strict_date(&self) -> ValResult { + match self { + Self::String(s) => bytes_as_date(self, py_string_str(s)?.as_bytes()), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::DateType, self)), + } + } + + fn strict_time( + &self, + microseconds_overflow_behavior: MicrosecondsPrecisionOverflowBehavior, + ) -> ValResult { + match self { + Self::String(s) => bytes_as_time(self, py_string_str(s)?.as_bytes(), microseconds_overflow_behavior), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::TimeType, self)), + } + } + + fn strict_datetime( + &self, + microseconds_overflow_behavior: MicrosecondsPrecisionOverflowBehavior, + ) -> ValResult { + match self { + Self::String(s) => bytes_as_datetime(self, py_string_str(s)?.as_bytes(), microseconds_overflow_behavior), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::DatetimeType, self)), + } + } + + fn strict_timedelta( + &self, + microseconds_overflow_behavior: MicrosecondsPrecisionOverflowBehavior, + ) -> ValResult { + match self { + Self::String(s) => bytes_as_timedelta(self, py_string_str(s)?.as_bytes(), microseconds_overflow_behavior), + Self::Mapping(_) => Err(ValError::new(ErrorTypeDefaults::TimeDeltaType, self)), + } + } +} + +impl BorrowInput for StringMapping<'_> { + type Input<'a> = StringMapping<'a> where Self: 'a; + fn borrow_input(&self) -> &Self::Input<'_> { + self + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index c15f54b2a..22d774a8c 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -6,6 +6,7 @@ mod datetime; mod input_abstract; mod input_json; mod input_python; +mod input_string; mod parse_json; mod return_enums; mod shared; @@ -15,12 +16,13 @@ pub(crate) use datetime::{ duration_as_pytimedelta, pydate_as_date, pydatetime_as_datetime, pytime_as_time, EitherDate, EitherDateTime, EitherTime, EitherTimedelta, }; -pub(crate) use input_abstract::{Input, InputType}; -pub(crate) use parse_json::{JsonInput, JsonObject}; +pub(crate) use input_abstract::{BorrowInput, Input, InputType}; +pub(crate) use input_string::StringMapping; +pub(crate) use parse_json::{JsonArray, JsonInput, JsonObject}; pub(crate) use return_enums::{ py_string_str, AttributesGenericIterator, DictGenericIterator, EitherBytes, EitherFloat, EitherInt, EitherString, GenericArguments, GenericIterable, GenericIterator, GenericMapping, Int, JsonArgs, JsonObjectGenericIterator, - MappingGenericIterator, PyArgs, + MappingGenericIterator, PyArgs, StringMappingGenericIterator, }; // Defined here as it's not exported by pyo3 diff --git a/src/input/return_enums.rs b/src/input/return_enums.rs index e97fe8b81..56f86f580 100644 --- a/src/input/return_enums.rs +++ b/src/input/return_enums.rs @@ -25,6 +25,7 @@ use crate::errors::{py_err_string, ErrorType, ErrorTypeDefaults, InputValue, Val use crate::tools::py_err; use crate::validators::{CombinedValidator, ValidationState, Validator}; +use super::input_string::StringMapping; use super::parse_json::{JsonArray, JsonInput, JsonObject}; use super::{py_error_on_minusone, Input}; @@ -429,6 +430,7 @@ impl<'a> GenericIterable<'a> { pub enum GenericMapping<'a> { PyDict(&'a PyDict), PyMapping(&'a PyMapping), + StringMapping(&'a PyDict), PyGetAttr(&'a PyAny, Option<&'a PyDict>), JsonObject(&'a JsonObject), } @@ -506,6 +508,38 @@ impl<'py> Iterator for MappingGenericIterator<'py> { } } +pub struct StringMappingGenericIterator<'py> { + dict_iter: PyDictIterator<'py>, +} + +impl<'py> StringMappingGenericIterator<'py> { + pub fn new(dict: &'py PyDict) -> ValResult<'py, Self> { + Ok(Self { dict_iter: dict.iter() }) + } +} + +impl<'py> Iterator for StringMappingGenericIterator<'py> { + // key (the first member of the tuple could be a simple String) + type Item = ValResult<'py, (StringMapping<'py>, StringMapping<'py>)>; + + fn next(&mut self) -> Option { + match self.dict_iter.next() { + Some((py_key, py_value)) => { + let key = match StringMapping::new_key(py_key) { + Ok(key) => key, + Err(e) => return Some(Err(e)), + }; + let value = match StringMapping::new_value(py_value) { + Ok(value) => value, + Err(e) => return Some(Err(e)), + }; + Some(Ok((key, value))) + } + None => None, + } + } +} + pub struct AttributesGenericIterator<'py> { object: &'py PyAny, // PyO3 should export this type upstream @@ -691,6 +725,7 @@ impl<'a> JsonArgs<'a> { pub enum GenericArguments<'a> { Py(PyArgs<'a>), Json(JsonArgs<'a>), + StringMapping(&'a PyDict), } impl<'a> From> for GenericArguments<'a> { diff --git a/src/input/shared.rs b/src/input/shared.rs index d8733bd31..1a8e2b61c 100644 --- a/src/input/shared.rs +++ b/src/input/shared.rs @@ -2,9 +2,9 @@ use num_bigint::BigInt; use pyo3::{intern, PyAny, Python}; use crate::errors::{ErrorType, ErrorTypeDefaults, ValError, ValResult}; -use crate::input::EitherInt; -use super::{EitherFloat, Input}; +use super::parse_json::{JsonArray, JsonInput}; +use super::{EitherFloat, EitherInt, Input}; pub fn map_json_err<'a>(input: &'a impl Input<'a>, error: serde_json::Error) -> ValError<'a> { ValError::new( @@ -150,3 +150,7 @@ pub fn decimal_as_int<'a>(py: Python, input: &'a impl Input<'a>, decimal: &'a Py } Ok(EitherInt::Py(numerator)) } + +pub fn string_to_vec(s: &str) -> JsonArray { + JsonArray::new(s.chars().map(|c| JsonInput::String(c.to_string())).collect()) +} diff --git a/src/lookup_key.rs b/src/lookup_key.rs index 49915e3b4..36190c069 100644 --- a/src/lookup_key.rs +++ b/src/lookup_key.rs @@ -6,8 +6,8 @@ use pyo3::prelude::*; use pyo3::types::{PyDict, PyList, PyMapping, PyString}; use crate::build_tools::py_schema_err; -use crate::errors::{ErrorType, ValLineError}; -use crate::input::{Input, JsonInput, JsonObject}; +use crate::errors::{py_err_string, ErrorType, ValError, ValLineError, ValResult}; +use crate::input::{Input, JsonInput, JsonObject, StringMapping}; use crate::tools::{extract_i64, py_err}; /// Used for getting items from python dicts, python objects, or JSON objects, in different ways @@ -109,7 +109,7 @@ impl LookupKey { pub fn py_get_dict_item<'data, 's>( &'s self, dict: &'data PyDict, - ) -> PyResult> { + ) -> ValResult<'data, Option<(&'s LookupPath, &'data PyAny)>> { match self { Self::Simple { py_key, path, .. } => match dict.get_item(py_key) { Some(value) => Ok(Some((path, value))), @@ -143,10 +143,22 @@ impl LookupKey { } } + pub fn py_get_string_mapping_item<'data, 's>( + &'s self, + dict: &'data PyDict, + ) -> ValResult<'data, Option<(&'s LookupPath, StringMapping<'data>)>> { + if let Some((path, py_any)) = self.py_get_dict_item(dict)? { + let value = StringMapping::new_value(py_any)?; + Ok(Some((path, value))) + } else { + Ok(None) + } + } + pub fn py_get_mapping_item<'data, 's>( &'s self, dict: &'data PyMapping, - ) -> PyResult> { + ) -> ValResult<'data, Option<(&'s LookupPath, &'data PyAny)>> { match self { Self::Simple { py_key, path, .. } => match dict.get_item(py_key) { Ok(value) => Ok(Some((path, value))), @@ -184,6 +196,23 @@ impl LookupKey { &'s self, obj: &'data PyAny, kwargs: Option<&'data PyDict>, + ) -> ValResult<'data, Option<(&'s LookupPath, &'data PyAny)>> { + match self._py_get_attr(obj, kwargs) { + Ok(v) => Ok(v), + Err(err) => { + let error = py_err_string(obj.py(), err); + Err(ValError::new( + ErrorType::GetAttributeError { error, context: None }, + obj, + )) + } + } + } + + pub fn _py_get_attr<'data, 's>( + &'s self, + obj: &'data PyAny, + kwargs: Option<&'data PyDict>, ) -> PyResult> { if let Some(dict) = kwargs { if let Ok(Some(item)) = self.py_get_dict_item(dict) { @@ -235,7 +264,7 @@ impl LookupKey { pub fn json_get<'data, 's>( &'s self, dict: &'data JsonObject, - ) -> PyResult> { + ) -> ValResult<'data, Option<(&'s LookupPath, &'data JsonInput)>> { match self { Self::Simple { key, path, .. } => match dict.get(key) { Some(value) => Ok(Some((path, value))), diff --git a/src/validators/arguments.rs b/src/validators/arguments.rs index e75ff310a..2c0fe4a0a 100644 --- a/src/validators/arguments.rs +++ b/src/validators/arguments.rs @@ -323,6 +323,7 @@ impl Validator for ArgumentsValidator { match args { GenericArguments::Py(a) => process!(a, py_get_dict_item, py_get, py_slice), GenericArguments::Json(a) => process!(a, json_get, json_get, json_slice), + GenericArguments::StringMapping(_) => unimplemented!(), } if !errors.is_empty() { Err(ValError::LineErrors(errors)) diff --git a/src/validators/dataclass.rs b/src/validators/dataclass.rs index 7b7be282e..117596b9f 100644 --- a/src/validators/dataclass.rs +++ b/src/validators/dataclass.rs @@ -8,7 +8,7 @@ use ahash::AHashSet; use crate::build_tools::py_schema_err; use crate::build_tools::{is_strict, schema_or_config_same, ExtraBehavior}; use crate::errors::{ErrorType, ErrorTypeDefaults, ValError, ValLineError, ValResult}; -use crate::input::{GenericArguments, Input}; +use crate::input::{BorrowInput, GenericArguments, Input}; use crate::lookup_key::LookupKey; use crate::tools::SchemaDict; use crate::validators::function::convert_err; @@ -188,15 +188,21 @@ impl Validator for DataclassArgsValidator { kw_value = Some((lookup_path, value)); } } + let kw_value = kw_value + .as_ref() + .map(|(path, value)| (path, value.borrow_input())); match (pos_value, kw_value) { // found both positional and keyword arguments, error (Some(_), Some((_, kw_value))) => { - errors.push(ValLineError::new_with_loc( - ErrorTypeDefaults::MultipleArgumentValues, - kw_value, - field.name.clone(), - )); + errors.push( + ValLineError::new_with_loc( + ErrorTypeDefaults::MultipleArgumentValues, + kw_value, + field.name.clone(), + ) + .into_owned(py), + ); } // found a positional argument, validate it (Some(pos_value), None) => match field.validator.validate(py, pos_value, state) { @@ -216,10 +222,12 @@ impl Validator for DataclassArgsValidator { Ok(value) => set_item!(field, value), Err(ValError::LineErrors(line_errors)) => { errors.extend(line_errors.into_iter().map(|err| { - lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name) + lookup_path + .apply_error_loc(err, self.loc_by_alias, &field.name) + .into_owned(py) })); } - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), } } // found neither, check if there is a default value, otherwise error @@ -267,11 +275,14 @@ impl Validator for DataclassArgsValidator { // Unknown / extra field match self.extra_behavior { ExtraBehavior::Forbid => { - errors.push(ValLineError::new_with_loc( - ErrorTypeDefaults::UnexpectedKeywordArgument, - value, - raw_key.as_loc_item(), - )); + errors.push( + ValLineError::new_with_loc( + ErrorTypeDefaults::UnexpectedKeywordArgument, + value, + raw_key.as_loc_item(), + ) + .into_owned(py), + ); } ExtraBehavior::Ignore => {} ExtraBehavior::Allow => { @@ -310,9 +321,24 @@ impl Validator for DataclassArgsValidator { } }}; } + match args { GenericArguments::Py(a) => process!(a, py_get_dict_item, py_get, py_slice), GenericArguments::Json(a) => process!(a, json_get, json_get, json_slice), + GenericArguments::StringMapping(a) => { + // StringMapping cannot pass positional args, so wrap the PyDict + // in a type with guaranteed empty args array for sake of the process + // macro + struct StringMappingArgs<'a> { + args: Option<&'a PyTuple>, + kwargs: Option<&'a PyDict>, + } + let a = StringMappingArgs { + args: None, + kwargs: Some(a), + }; + process!(a, py_get_string_mapping_item, py_get, py_slice); + } } Ok(()) }, diff --git a/src/validators/dict.rs b/src/validators/dict.rs index 6a52b7f79..dc8f03937 100644 --- a/src/validators/dict.rs +++ b/src/validators/dict.rs @@ -4,7 +4,11 @@ use pyo3::types::PyDict; use crate::build_tools::is_strict; use crate::errors::{ValError, ValLineError, ValResult}; -use crate::input::{DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, MappingGenericIterator}; +use crate::input::BorrowInput; +use crate::input::{ + DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, MappingGenericIterator, + StringMappingGenericIterator, +}; use crate::tools::SchemaDict; @@ -78,6 +82,9 @@ impl Validator for DictValidator { GenericMapping::PyMapping(mapping) => { self.validate_generic_mapping(py, input, MappingGenericIterator::new(mapping)?, state) } + GenericMapping::StringMapping(dict) => { + self.validate_generic_mapping(py, input, StringMappingGenericIterator::new(dict)?, state) + } GenericMapping::PyGetAttr(_, _) => unreachable!(), GenericMapping::JsonObject(json_object) => { self.validate_generic_mapping(py, input, JsonObjectGenericIterator::new(json_object)?, state) @@ -113,9 +120,7 @@ impl DictValidator { &'s self, py: Python<'data>, input: &'data impl Input<'data>, - mapping_iter: impl Iterator< - Item = ValResult<'data, (&'data (impl Input<'data> + 'data), &'data (impl Input<'data> + 'data))>, - >, + mapping_iter: impl Iterator>, state: &mut ValidationState, ) -> ValResult<'data, PyObject> { let output = PyDict::new(py); @@ -125,6 +130,8 @@ impl DictValidator { let value_validator = self.value_validator.as_ref(); for item_result in mapping_iter { let (key, value) = item_result?; + let key = key.borrow_input(); + let value = value.borrow_input(); let output_key = match key_validator.validate(py, key, state) { Ok(value) => Some(value), Err(ValError::LineErrors(line_errors)) => { @@ -132,24 +139,25 @@ impl DictValidator { // these are added in reverse order so [key] is shunted along by the second call errors.push( err.with_outer_location("[key]".into()) - .with_outer_location(key.as_loc_item()), + .with_outer_location(key.as_loc_item()) + .into_owned(py), ); } None } Err(ValError::Omit) => continue, - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), }; let output_value = match value_validator.validate(py, value, state) { Ok(value) => Some(value), Err(ValError::LineErrors(line_errors)) => { for err in line_errors { - errors.push(err.with_outer_location(key.as_loc_item())); + errors.push(err.with_outer_location(key.as_loc_item()).into_owned(py)); } None } Err(ValError::Omit) => continue, - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), }; if let (Some(key), Some(value)) = (output_key, output_value) { output.set_item(key, value)?; diff --git a/src/validators/function.rs b/src/validators/function.rs index 206e10a0c..29a6f405e 100644 --- a/src/validators/function.rs +++ b/src/validators/function.rs @@ -544,7 +544,7 @@ impl ValidationInfo { context: extra.context.map(Into::into), field_name, data: extra.data.map(Into::into), - mode: extra.mode, + mode: extra.input_type, } } } diff --git a/src/validators/generator.rs b/src/validators/generator.rs index c52910500..bf6d009e1 100644 --- a/src/validators/generator.rs +++ b/src/validators/generator.rs @@ -3,7 +3,7 @@ use std::fmt; use pyo3::prelude::*; use pyo3::types::PyDict; -use crate::errors::{ErrorMode, ErrorType, LocItem, ValError, ValResult}; +use crate::errors::{ErrorType, LocItem, ValError, ValResult}; use crate::input::{GenericIterator, Input}; use crate::recursion_guard::RecursionGuard; use crate::tools::SchemaDict; @@ -153,7 +153,7 @@ impl ValidatorIterator { return Err(ValidationError::from_val_error( py, "ValidatorIterator".to_object(py), - ErrorMode::Python, + InputType::Python, val_error, None, hide_input_in_errors, @@ -180,7 +180,7 @@ impl ValidatorIterator { return Err(ValidationError::from_val_error( py, "ValidatorIterator".to_object(py), - ErrorMode::Python, + InputType::Python, val_error, None, hide_input_in_errors, @@ -262,7 +262,7 @@ impl InternalValidator { context: extra.context.map(|d| d.into_py(py)), self_instance: extra.self_instance.map(|d| d.into_py(py)), recursion_guard: state.recursion_guard.clone(), - validation_mode: extra.mode, + validation_mode: extra.input_type, hide_input_in_errors, validation_error_cause, } @@ -277,7 +277,7 @@ impl InternalValidator { outer_location: Option, ) -> PyResult { let extra = Extra { - mode: self.validation_mode, + input_type: self.validation_mode, data: self.data.as_ref().map(|data| data.as_ref(py)), strict: self.strict, ultra_strict: false, @@ -292,7 +292,7 @@ impl InternalValidator { ValidationError::from_val_error( py, self.name.to_object(py), - ErrorMode::Python, + InputType::Python, e, outer_location, self.hide_input_in_errors, @@ -308,7 +308,7 @@ impl InternalValidator { outer_location: Option, ) -> PyResult { let extra = Extra { - mode: self.validation_mode, + input_type: self.validation_mode, data: self.data.as_ref().map(|data| data.as_ref(py)), strict: self.strict, ultra_strict: false, @@ -321,7 +321,7 @@ impl InternalValidator { ValidationError::from_val_error( py, self.name.to_object(py), - ErrorMode::Python, + InputType::Python, e, outer_location, self.hide_input_in_errors, diff --git a/src/validators/json_or_python.rs b/src/validators/json_or_python.rs index e62620027..828532fe5 100644 --- a/src/validators/json_or_python.rs +++ b/src/validators/json_or_python.rs @@ -57,9 +57,9 @@ impl Validator for JsonOrPython { input: &'data impl Input<'data>, state: &mut ValidationState, ) -> ValResult<'data, PyObject> { - match state.extra().mode { + match state.extra().input_type { InputType::Python => self.python.validate(py, input, state), - InputType::Json => self.json.validate(py, input, state), + _ => self.json.validate(py, input, state), } } diff --git a/src/validators/mod.rs b/src/validators/mod.rs index 9a9c6a185..38ceb7332 100644 --- a/src/validators/mod.rs +++ b/src/validators/mod.rs @@ -10,8 +10,8 @@ use pyo3::{intern, PyTraverseError, PyVisit}; use crate::build_tools::{py_schema_err, py_schema_error_type, SchemaError}; use crate::definitions::DefinitionsBuilder; -use crate::errors::{ErrorMode, LocItem, ValError, ValResult, ValidationError}; -use crate::input::{Input, InputType}; +use crate::errors::{LocItem, ValError, ValResult, ValidationError}; +use crate::input::{Input, InputType, StringMapping}; use crate::py_gc::PyGcTraverse; use crate::recursion_guard::RecursionGuard; use crate::tools::SchemaDict; @@ -171,7 +171,7 @@ impl SchemaValidator { self_instance, &mut RecursionGuard::default(), ) - .map_err(|e| self.prepare_validation_err(py, e, ErrorMode::Python)) + .map_err(|e| self.prepare_validation_err(py, e, InputType::Python)) } #[pyo3(signature = (input, *, strict=None, from_attributes=None, context=None, self_instance=None))] @@ -224,8 +224,26 @@ impl SchemaValidator { self_instance, recursion_guard, ) - .map_err(|e| self.prepare_validation_err(py, e, ErrorMode::Json)), - Err(err) => Err(self.prepare_validation_err(py, err, ErrorMode::Json)), + .map_err(|e| self.prepare_validation_err(py, e, InputType::Json)), + Err(err) => Err(self.prepare_validation_err(py, err, InputType::Json)), + } + } + + #[pyo3(signature = (input, *, strict=None, context=None))] + pub fn validate_strings( + &self, + py: Python, + input: &PyAny, + strict: Option, + context: Option<&PyAny>, + ) -> PyResult { + let t = InputType::String; + let string_mapping = StringMapping::new_value(input).map_err(|e| self.prepare_validation_err(py, e, t))?; + + let recursion_guard = &mut RecursionGuard::default(); + match self._validate(py, &string_mapping, t, strict, None, context, None, recursion_guard) { + Ok(r) => Ok(r), + Err(e) => Err(self.prepare_validation_err(py, e, t)), } } @@ -242,7 +260,7 @@ impl SchemaValidator { context: Option<&PyAny>, ) -> PyResult { let extra = Extra { - mode: InputType::Python, + input_type: InputType::Python, data: None, strict, from_attributes, @@ -255,13 +273,13 @@ impl SchemaValidator { let mut state = ValidationState::new(extra, &self.definitions, guard); self.validator .validate_assignment(py, obj, field_name, field_value, &mut state) - .map_err(|e| self.prepare_validation_err(py, e, ErrorMode::Python)) + .map_err(|e| self.prepare_validation_err(py, e, InputType::Python)) } #[pyo3(signature = (*, strict=None, context=None))] pub fn get_default_value(&self, py: Python, strict: Option, context: Option<&PyAny>) -> PyResult { let extra = Extra { - mode: InputType::Python, + input_type: InputType::Python, data: None, strict, from_attributes: None, @@ -277,7 +295,7 @@ impl SchemaValidator { Some(v) => Ok(PySome::new(v).into_py(py)), None => Ok(py.None().into_py(py)), }, - Err(e) => Err(self.prepare_validation_err(py, e, ErrorMode::Python)), + Err(e) => Err(self.prepare_validation_err(py, e, InputType::Python)), } } @@ -306,7 +324,7 @@ impl SchemaValidator { &'data self, py: Python<'data>, input: &'data impl Input<'data>, - mode: InputType, + input_type: InputType, strict: Option, from_attributes: Option, context: Option<&'data PyAny>, @@ -317,18 +335,18 @@ impl SchemaValidator { 's: 'data, { let mut state = ValidationState::new( - Extra::new(strict, from_attributes, context, self_instance, mode), + Extra::new(strict, from_attributes, context, self_instance, input_type), &self.definitions, recursion_guard, ); self.validator.validate(py, input, &mut state) } - fn prepare_validation_err(&self, py: Python, error: ValError, error_mode: ErrorMode) -> PyErr { + fn prepare_validation_err(&self, py: Python, error: ValError, input_type: InputType) -> PyErr { ValidationError::from_val_error( py, self.title.clone_ref(py), - error_mode, + input_type, error, None, self.hide_input_in_errors, @@ -541,7 +559,7 @@ pub fn build_validator<'a>( #[derive(Debug)] pub struct Extra<'a> { /// Validation mode - pub mode: InputType, + pub input_type: InputType, /// This is used as the `data` kwargs to validator functions pub data: Option<&'a PyDict>, /// whether we're in strict or lax mode @@ -562,10 +580,10 @@ impl<'a> Extra<'a> { from_attributes: Option, context: Option<&'a PyAny>, self_instance: Option<&'a PyAny>, - mode: InputType, + input_type: InputType, ) -> Self { Extra { - mode, + input_type, data: None, strict, ultra_strict: false, @@ -579,7 +597,7 @@ impl<'a> Extra<'a> { impl<'a> Extra<'a> { pub fn as_strict(&self, ultra_strict: bool) -> Self { Self { - mode: self.mode, + input_type: self.input_type, data: self.data, strict: Some(true), ultra_strict, diff --git a/src/validators/model_fields.rs b/src/validators/model_fields.rs index 29e9522f2..f2654c33e 100644 --- a/src/validators/model_fields.rs +++ b/src/validators/model_fields.rs @@ -7,10 +7,10 @@ use ahash::AHashSet; use crate::build_tools::py_schema_err; use crate::build_tools::{is_strict, schema_or_config_same, ExtraBehavior}; -use crate::errors::{py_err_string, ErrorType, ErrorTypeDefaults, ValError, ValLineError, ValResult}; +use crate::errors::{ErrorType, ErrorTypeDefaults, ValError, ValLineError, ValResult}; use crate::input::{ - AttributesGenericIterator, DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, - MappingGenericIterator, + AttributesGenericIterator, BorrowInput, DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, + MappingGenericIterator, StringMappingGenericIterator, }; use crate::lookup_key::LookupKey; use crate::tools::SchemaDict; @@ -180,17 +180,13 @@ impl Validator for ModelFieldsValidator { for field in &self.fields { let op_key_value = match field.lookup_key.$get_method($dict $(, $kwargs )? ) { Ok(v) => v, - Err(err) => { - errors.push(ValLineError::new_with_loc( - ErrorType::GetAttributeError { - error: py_err_string(py, err), - context: None, - }, - input, - field.name.clone(), - )); + Err(ValError::LineErrors(line_errors)) => { + for err in line_errors { + errors.push(err.with_outer_location(field.name.as_loc_item())); + } continue; } + Err(err) => return ControlFlow::Break(err), }; if let Some((lookup_path, value)) = op_key_value { if let Some(ref mut used_keys) = used_keys { @@ -198,10 +194,7 @@ impl Validator for ModelFieldsValidator { // extra logic either way used_keys.insert(lookup_path.first_key()); } - match field - .validator - .validate(py, value, state) - { + match field.validator.validate(py, value.borrow_input(), state) { Ok(value) => { control_flow!(model_dict.set_item(&field.name_py, value))?; fields_set_vec.push(field.name_py.clone_ref(py)); @@ -209,10 +202,13 @@ impl Validator for ModelFieldsValidator { Err(ValError::Omit) => continue, Err(ValError::LineErrors(line_errors)) => { for err in line_errors { - errors.push(lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name)); + errors.push( + lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name) + .into_owned(py) + ); } } - Err(err) => return ControlFlow::Break(err), + Err(err) => return ControlFlow::Break(err.into_owned(py)), } continue; } else if let Some(value) = control_flow!(field.validator.default_value(py, Some(field.name.as_str()), state))? { @@ -242,25 +238,31 @@ impl Validator for ModelFieldsValidator { for err in line_errors { errors.push( err.with_outer_location(raw_key.as_loc_item()) - .with_type(ErrorTypeDefaults::InvalidKey), + .with_type(ErrorTypeDefaults::InvalidKey) + .into_owned(py) ); } continue; } - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), }; - if used_keys.contains(either_str.as_cow()?.as_ref()) { + let cow = either_str.as_cow().map_err(|err| err.into_owned(py))?; + if used_keys.contains(cow.as_ref()) { continue; } + let value = value.borrow_input(); // Unknown / extra field match self.extra_behavior { ExtraBehavior::Forbid => { - errors.push(ValLineError::new_with_loc( - ErrorTypeDefaults::ExtraForbidden, - value, - raw_key.as_loc_item(), - )); + errors.push( + ValLineError::new_with_loc( + ErrorTypeDefaults::ExtraForbidden, + value, + raw_key.as_loc_item(), + ) + .into_owned(py) + ); } ExtraBehavior::Ignore => {} ExtraBehavior::Allow => { @@ -273,10 +275,10 @@ impl Validator for ModelFieldsValidator { } Err(ValError::LineErrors(line_errors)) => { for err in line_errors { - errors.push(err.with_outer_location(raw_key.as_loc_item())); + errors.push(err.with_outer_location(raw_key.as_loc_item()).into_owned(py)); } } - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), } } else { model_extra_dict.set_item(py_key, value.to_object(py))?; @@ -293,8 +295,9 @@ impl Validator for ModelFieldsValidator { } match dict { GenericMapping::PyDict(d) => process!(d, py_get_dict_item, DictGenericIterator), - GenericMapping::PyGetAttr(d, kwargs) => process!(d, py_get_attr, AttributesGenericIterator, kwargs), GenericMapping::PyMapping(d) => process!(d, py_get_mapping_item, MappingGenericIterator), + GenericMapping::StringMapping(d) => process!(d, py_get_string_mapping_item, StringMappingGenericIterator), + GenericMapping::PyGetAttr(d, kwargs) => process!(d, py_get_attr, AttributesGenericIterator, kwargs), GenericMapping::JsonObject(d) => process!(d, json_get, JsonObjectGenericIterator), } diff --git a/src/validators/typed_dict.rs b/src/validators/typed_dict.rs index a095a52f1..56e4a8225 100644 --- a/src/validators/typed_dict.rs +++ b/src/validators/typed_dict.rs @@ -1,3 +1,5 @@ +use std::ops::ControlFlow; + use pyo3::intern; use pyo3::prelude::*; use pyo3::types::{PyDict, PyString}; @@ -6,10 +8,10 @@ use ahash::AHashSet; use crate::build_tools::py_schema_err; use crate::build_tools::{is_strict, schema_or_config, schema_or_config_same, ExtraBehavior}; -use crate::errors::{py_err_string, ErrorType, ErrorTypeDefaults, ValError, ValLineError, ValResult}; +use crate::errors::{ErrorTypeDefaults, ValError, ValLineError, ValResult}; use crate::input::{ - AttributesGenericIterator, DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, - MappingGenericIterator, + AttributesGenericIterator, BorrowInput, DictGenericIterator, GenericMapping, Input, JsonObjectGenericIterator, + MappingGenericIterator, StringMappingGenericIterator, }; use crate::lookup_key::LookupKey; use crate::tools::SchemaDict; @@ -18,8 +20,6 @@ use super::{ build_validator, BuildValidator, CombinedValidator, DefinitionsBuilder, Extra, ValidationState, Validator, }; -use std::ops::ControlFlow; - #[derive(Debug, Clone)] struct TypedDictField { name: String, @@ -181,17 +181,13 @@ impl Validator for TypedDictValidator { for field in &self.fields { let op_key_value = match field.lookup_key.$get_method($dict $(, $kwargs )? ) { Ok(v) => v, - Err(err) => { - errors.push(ValLineError::new_with_loc( - ErrorType::GetAttributeError { - error: py_err_string(py, err), - context: None, - }, - input, - field.name.clone(), - )); + Err(ValError::LineErrors(line_errors)) => { + for err in line_errors { + errors.push(err.with_outer_location(field.name.as_loc_item())); + } continue; } + Err(err) => return ControlFlow::Break(err), }; if let Some((lookup_path, value)) = op_key_value { if let Some(ref mut used_keys) = used_keys { @@ -199,17 +195,21 @@ impl Validator for TypedDictValidator { // extra logic either way used_keys.insert(lookup_path.first_key()); } - match field.validator.validate(py, value, state) { + match field.validator.validate(py, value.borrow_input(), state) { Ok(value) => { control_flow!(output_dict.set_item(&field.name_py, value))?; } Err(ValError::Omit) => continue, Err(ValError::LineErrors(line_errors)) => { for err in line_errors { - errors.push(lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name)); + errors.push( + lookup_path + .apply_error_loc(err, self.loc_by_alias, &field.name) + .into_owned(py) + ); } } - Err(err) => return ControlFlow::Break(err), + Err(err) => return ControlFlow::Break(err.into_owned(py)), } continue; } else if let Some(value) = control_flow!(field.validator.default_value(py, Some(field.name.as_str()), state))? { @@ -238,25 +238,31 @@ impl Validator for TypedDictValidator { for err in line_errors { errors.push( err.with_outer_location(raw_key.as_loc_item()) - .with_type(ErrorTypeDefaults::InvalidKey), + .with_type(ErrorTypeDefaults::InvalidKey) + .into_owned(py) ); } continue; } - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), }; - if used_keys.contains(either_str.as_cow()?.as_ref()) { + let cow = either_str.as_cow().map_err(|err| err.into_owned(py))?; + if used_keys.contains(cow.as_ref()) { continue; } + let value = value.borrow_input(); // Unknown / extra field match self.extra_behavior { ExtraBehavior::Forbid => { - errors.push(ValLineError::new_with_loc( - ErrorTypeDefaults::ExtraForbidden, - value, - raw_key.as_loc_item(), - )); + errors.push( + ValLineError::new_with_loc( + ErrorTypeDefaults::ExtraForbidden, + value, + raw_key.as_loc_item(), + ) + .into_owned(py) + ); } ExtraBehavior::Ignore => {} ExtraBehavior::Allow => { @@ -268,10 +274,14 @@ impl Validator for TypedDictValidator { } Err(ValError::LineErrors(line_errors)) => { for err in line_errors { - errors.push(err.with_outer_location(raw_key.as_loc_item())); + errors.push( + err + .with_outer_location(raw_key.as_loc_item()) + .into_owned(py) + ); } } - Err(err) => return Err(err), + Err(err) => return Err(err.into_owned(py)), } } else { output_dict.set_item(py_key, value.to_object(py))?; @@ -284,8 +294,9 @@ impl Validator for TypedDictValidator { } match dict { GenericMapping::PyDict(d) => process!(d, py_get_dict_item, DictGenericIterator), - GenericMapping::PyGetAttr(d, kwargs) => process!(d, py_get_attr, AttributesGenericIterator, kwargs), GenericMapping::PyMapping(d) => process!(d, py_get_mapping_item, MappingGenericIterator), + GenericMapping::StringMapping(d) => process!(d, py_get_string_mapping_item, StringMappingGenericIterator), + GenericMapping::PyGetAttr(d, kwargs) => process!(d, py_get_attr, AttributesGenericIterator, kwargs), GenericMapping::JsonObject(d) => process!(d, json_get, JsonObjectGenericIterator), } diff --git a/src/validators/union.rs b/src/validators/union.rs index 385a1f49d..4d3b0bd78 100644 --- a/src/validators/union.rs +++ b/src/validators/union.rs @@ -455,8 +455,9 @@ impl Validator for TaggedUnionValidator { let dict = input.validate_model_fields(self.strict, from_attributes)?; let tag = match dict { GenericMapping::PyDict(dict) => find_validator!(py_get_dict_item, dict), - GenericMapping::PyGetAttr(obj, kwargs) => find_validator!(py_get_attr, obj, kwargs), GenericMapping::PyMapping(mapping) => find_validator!(py_get_mapping_item, mapping), + GenericMapping::StringMapping(d) => find_validator!(py_get_dict_item, d), + GenericMapping::PyGetAttr(obj, kwargs) => find_validator!(py_get_attr, obj, kwargs), GenericMapping::JsonObject(mapping) => find_validator!(json_get, mapping), }?; self.find_call_validator(py, tag, input, state) diff --git a/src/validators/validation_state.rs b/src/validators/validation_state.rs index 75d6dae66..6cf5ce313 100644 --- a/src/validators/validation_state.rs +++ b/src/validators/validation_state.rs @@ -44,6 +44,7 @@ impl<'a> ValidationState<'a> { &'state mut self, f: impl FnOnce(&mut Extra<'a>), ) -> ValidationStateWithReboundExtra<'state, 'a> { + #[allow(clippy::unnecessary_struct_initialization)] let old_extra = Extra { ..self.extra }; f(&mut self.extra); ValidationStateWithReboundExtra { state: self, old_extra } diff --git a/tests/conftest.py b/tests/conftest.py index a5f5cc344..a83c49472 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -59,6 +59,9 @@ def __init__( def validate_python(self, py_input, strict: bool | None = None, context: Any = None): return self.validator.validate_python(py_input, strict=strict, context=context) + def validate_json(self, json_str: str, strict: bool | None = None, context: Any = None): + return self.validator.validate_json(json_str, strict=strict, context=context) + def validate_test(self, py_input, strict: bool | None = None, context: Any = None): if self.validator_type == 'json': return self.validator.validate_json( diff --git a/tests/test_validate_strings.py b/tests/test_validate_strings.py new file mode 100644 index 000000000..0e0350d0b --- /dev/null +++ b/tests/test_validate_strings.py @@ -0,0 +1,121 @@ +import dataclasses +import re +from datetime import date, datetime + +import pytest + +from pydantic_core import SchemaValidator, ValidationError, core_schema + +from .conftest import Err + + +def test_bool(): + v = SchemaValidator(core_schema.bool_schema()) + + assert v.validate_strings('true') is True + assert v.validate_strings('true', strict=True) is True + assert v.validate_strings('false') is False + + +@pytest.mark.parametrize( + 'schema,input_value,expected,strict', + [ + (core_schema.int_schema(), '1', 1, False), + (core_schema.int_schema(), '1', 1, True), + (core_schema.int_schema(), 'xxx', Err('type=int_parsing'), True), + (core_schema.float_schema(), '1.1', 1.1, False), + (core_schema.float_schema(), '1.10', 1.1, False), + (core_schema.float_schema(), '1.1', 1.1, True), + (core_schema.float_schema(), '1.10', 1.1, True), + (core_schema.date_schema(), '2017-01-01', date(2017, 1, 1), False), + (core_schema.date_schema(), '2017-01-01', date(2017, 1, 1), True), + (core_schema.datetime_schema(), '2017-01-01T12:13:14.567', datetime(2017, 1, 1, 12, 13, 14, 567_000), False), + (core_schema.datetime_schema(), '2017-01-01T12:13:14.567', datetime(2017, 1, 1, 12, 13, 14, 567_000), True), + (core_schema.date_schema(), '2017-01-01T12:13:14.567', Err('type=date_from_datetime_inexact'), False), + (core_schema.date_schema(), '2017-01-01T12:13:14.567', Err('type=date_parsing'), True), + (core_schema.date_schema(), '2017-01-01T00:00:00', date(2017, 1, 1), False), + (core_schema.date_schema(), '2017-01-01T00:00:00', Err('type=date_parsing'), True), + ], + ids=repr, +) +def test_validate_strings(schema, input_value, expected, strict): + v = SchemaValidator(schema) + if isinstance(expected, Err): + with pytest.raises(ValidationError, match=re.escape(expected.message)): + v.validate_strings(input_value, strict=strict) + else: + assert v.validate_strings(input_value, strict=strict) == expected + + +def test_dict(): + v = SchemaValidator(core_schema.dict_schema(core_schema.int_schema(), core_schema.date_schema())) + + assert v.validate_strings({'1': '2017-01-01', '2': '2017-01-02'}) == {1: date(2017, 1, 1), 2: date(2017, 1, 2)} + assert v.validate_strings({'1': '2017-01-01', '2': '2017-01-02'}, strict=True) == { + 1: date(2017, 1, 1), + 2: date(2017, 1, 2), + } + + +def test_model(): + class MyModel: + # this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__` + __slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__' + field_a: int + field_b: date + + v = SchemaValidator( + core_schema.model_schema( + MyModel, + core_schema.model_fields_schema( + { + 'field_a': core_schema.model_field(core_schema.int_schema()), + 'field_b': core_schema.model_field(core_schema.date_schema()), + } + ), + ) + ) + m2 = v.validate_strings({'field_a': '1', 'field_b': '2017-01-01'}) + assert m2.__dict__ == {'field_a': 1, 'field_b': date(2017, 1, 1)} + m2 = v.validate_strings({'field_a': '1', 'field_b': '2017-01-01'}, strict=True) + assert m2.__dict__ == {'field_a': 1, 'field_b': date(2017, 1, 1)} + + +def test_dataclass(): + @dataclasses.dataclass + class MyDataClass: + field_a: int + field_b: date + + v = SchemaValidator( + core_schema.dataclass_schema( + MyDataClass, + core_schema.dataclass_args_schema( + 'MyDataClass', + [ + core_schema.dataclass_field('field_a', core_schema.int_schema()), + core_schema.dataclass_field('field_b', core_schema.date_schema()), + ], + ), + ['field_a', 'field_b'], + ) + ) + m2 = v.validate_strings({'field_a': '1', 'field_b': '2017-01-01'}) + assert m2.__dict__ == {'field_a': 1, 'field_b': date(2017, 1, 1)} + m2 = v.validate_strings({'field_a': '1', 'field_b': '2017-01-01'}, strict=True) + assert m2.__dict__ == {'field_a': 1, 'field_b': date(2017, 1, 1)} + + +def test_typed_dict(): + v = SchemaValidator( + core_schema.typed_dict_schema( + { + 'field_a': core_schema.typed_dict_field(core_schema.int_schema()), + 'field_b': core_schema.typed_dict_field(core_schema.date_schema()), + } + ) + ) + m2 = v.validate_strings({'field_a': '1', 'field_b': '2017-01-01'}) + assert m2 == {'field_a': 1, 'field_b': date(2017, 1, 1)} + m2 = v.validate_strings({'field_a': '1', 'field_b': '2017-01-01'}, strict=True) + assert m2 == {'field_a': 1, 'field_b': date(2017, 1, 1)} diff --git a/tests/validators/test_bool.py b/tests/validators/test_bool.py index 714c07ffe..e71d41cb1 100644 --- a/tests/validators/test_bool.py +++ b/tests/validators/test_bool.py @@ -88,7 +88,8 @@ def test_bool_key(py_and_json: PyAndJson): assert v.validate_test({'true': 1, 'off': 2}) == {True: 1, False: 2} assert v.validate_test({'true': 1, 'off': 2}, strict=False) == {True: 1, False: 2} with pytest.raises(ValidationError, match='Input should be a valid boolean'): - v.validate_test({'true': 1, 'off': 2}, strict=True) + v.validate_python({'true': 1, 'off': 2}, strict=True) + assert v.validate_json('{"true": 1, "off": 2}', strict=True) == {True: 1, False: 2} def test_validate_assignment_not_supported() -> None: diff --git a/tests/validators/test_float.py b/tests/validators/test_float.py index 80b96eacd..74f0024ca 100644 --- a/tests/validators/test_float.py +++ b/tests/validators/test_float.py @@ -215,7 +215,8 @@ def test_float_key(py_and_json: PyAndJson): assert v.validate_test({'1': 1, '2': 2}) == {1: 1, 2: 2} assert v.validate_test({'1.5': 1, '2.4': 2}) == {1.5: 1, 2.4: 2} with pytest.raises(ValidationError, match='Input should be a valid number'): - v.validate_test({'1.5': 1, '2.5': 2}, strict=True) + v.validate_python({'1.5': 1, '2.5': 2}, strict=True) + assert v.validate_json('{"1.5": 1, "2.5": 2}', strict=True) == {1.5: 1, 2.5: 2} @pytest.mark.parametrize( diff --git a/tests/validators/test_int.py b/tests/validators/test_int.py index 43cd5bacb..8d5850dc8 100644 --- a/tests/validators/test_int.py +++ b/tests/validators/test_int.py @@ -402,7 +402,8 @@ def test_int_key(py_and_json: PyAndJson): v = py_and_json({'type': 'dict', 'keys_schema': {'type': 'int'}, 'values_schema': {'type': 'int'}}) assert v.validate_test({'1': 1, '2': 2}) == {1: 1, 2: 2} with pytest.raises(ValidationError, match='Input should be a valid integer'): - v.validate_test({'1': 1, '2': 2}, strict=True) + v.validate_python({'1': 1, '2': 2}, strict=True) + assert v.validate_json('{"1": 1, "2": 2}', strict=True) == {1: 1, 2: 2} def test_string_as_int_with_underscores() -> None: diff --git a/tests/validators/test_json.py b/tests/validators/test_json.py index 83ddca172..d8666d335 100644 --- a/tests/validators/test_json.py +++ b/tests/validators/test_json.py @@ -50,26 +50,33 @@ def test_any(py_and_json: PyAndJson, input_value, expected): [ ('{"a": 1}', {'a': 1}), (b'{"a": 1}', {'a': 1}), + ( + '🐈 Hello \ud800World', + Err( + 'Input should be a valid string, unable to parse raw data as a unicode string ' + "[type=string_unicode, input_value='🐈 Hello \\ud800World', input_type=str]" + ), + ), (bytearray(b'{"a": 1}'), {'a': 1}), ( 'xx', Err( 'Invalid JSON: expected value at line 1 column 1 ' - "[type=json_invalid, input_value='xx', input_type=str" + "[type=json_invalid, input_value='xx', input_type=str]" ), ), ( b'xx', Err( 'Invalid JSON: expected value at line 1 column 1 ' - "[type=json_invalid, input_value=b'xx', input_type=bytes" + "[type=json_invalid, input_value=b'xx', input_type=bytes]" ), ), ( bytearray(b'xx'), Err( 'Invalid JSON: expected value at line 1 column 1 ' - "[type=json_invalid, input_value=bytearray(b'xx'), input_type=bytearray" + "[type=json_invalid, input_value=bytearray(b'xx'), input_type=bytearray]" ), ), ],