diff --git a/src/build_tools.rs b/src/build_tools.rs index 93569fbbe..0985fbadf 100644 --- a/src/build_tools.rs +++ b/src/build_tools.rs @@ -7,7 +7,7 @@ use pyo3::prelude::*; use pyo3::types::{PyDict, PyList, PyString}; use pyo3::{intern, FromPyObject, PyErrArguments}; -use crate::errors::ValError; +use crate::errors::{ErrorMode, ValError}; use crate::ValidationError; pub trait SchemaDict<'py> { @@ -140,7 +140,7 @@ impl SchemaError { match error { 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)); + let validation_error = ValidationError::new(line_errors, "Schema".to_object(py), ErrorMode::Python); 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 914629272..86b52a6f3 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, ErrorType}; +pub use self::types::{list_all_errors, ErrorMode, ErrorType}; pub use self::validation_exception::ValidationError; pub use self::value_exception::{PydanticCustomError, PydanticKnownError, PydanticOmit}; diff --git a/src/errors/types.rs b/src/errors/types.rs index 89c7d6a03..0eb241898 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::fmt; use ahash::AHashMap; -use pyo3::exceptions::{PyKeyError, PyTypeError}; +use pyo3::exceptions::{PyKeyError, PyTypeError, PyValueError}; use pyo3::once_cell::GILOnceCell; use pyo3::prelude::*; use pyo3::types::{PyDict, PyList}; @@ -13,6 +13,23 @@ use strum_macros::EnumIter; use super::PydanticCustomError; +#[derive(Clone, Debug)] +pub enum ErrorMode { + Python, + Json, +} + +impl ErrorMode { + pub fn from_raw(s: Option<&str>) -> PyResult { + match s { + None => Ok(Self::Python), + Some("python") => Ok(Self::Python), + Some("json") => Ok(Self::Json), + Some(s) => py_err!(PyValueError; "Invalid error mode: {}", s), + } + } +} + #[pyfunction] pub fn list_all_errors(py: Python) -> PyResult<&PyList> { let mut errors: Vec<&PyDict> = Vec::with_capacity(100); @@ -20,8 +37,8 @@ pub fn list_all_errors(py: Python) -> PyResult<&PyList> { if !matches!(error_type, ErrorType::CustomError { .. }) { let d = PyDict::new(py); d.set_item("type", error_type.to_string())?; - d.set_item("message_template", error_type.message_template())?; - d.set_item("example_message", error_type.render_message(py)?)?; + d.set_item("message_template", error_type.message_template_python())?; + d.set_item("example_message", error_type.render_message(py, &ErrorMode::Python)?)?; d.set_item("example_context", error_type.py_dict(py)?)?; errors.push(d); } @@ -39,91 +56,65 @@ pub fn list_all_errors(py: Python) -> PyResult<&PyList> { pub enum ErrorType { // --------------------- // Assignment errors - #[strum(message = "Object has no attribute '{attribute}'")] NoSuchAttribute { attribute: String, }, // --------------------- // JSON errors - #[strum(message = "Invalid JSON: {error}")] JsonInvalid { error: String, }, - #[strum(message = "JSON input should be string, bytes or bytearray")] JsonType, // --------------------- // recursion error - #[strum(message = "Recursion error - cyclic reference detected")] RecursionLoop, // --------------------- // typed dict specific errors - #[strum(message = "Input should be a valid dictionary or instance to extract fields from")] DictAttributesType, - #[strum(message = "Field required")] Missing, - #[strum(message = "Field is frozen")] FrozenField, - #[strum(message = "Instance is frozen")] FrozenInstance, - #[strum(message = "Extra inputs are not permitted")] ExtraForbidden, - #[strum(message = "Keys should be strings")] InvalidKey, - #[strum(message = "Error extracting attribute: {error}")] GetAttributeError { error: String, }, // --------------------- // model class specific errors - #[strum(message = "Input should be an instance of {class_name}")] ModelClassType { class_name: String, }, // --------------------- // None errors - #[strum(message = "Input should be None/null")] NoneRequired, // boolean errors - #[strum(message = "Input should be a valid boolean")] Bool, // --------------------- // generic comparison errors - used for all inequality comparisons except int and float which have their // own type, bounds arguments are Strings so they can be created from any type - #[strum(message = "Input should be greater than {gt}")] GreaterThan { gt: Number, }, - #[strum(message = "Input should be greater than or equal to {ge}")] GreaterThanEqual { ge: Number, }, - #[strum(message = "Input should be less than {lt}")] LessThan { lt: Number, }, - #[strum(message = "Input should be less than or equal to {le}")] LessThanEqual { le: Number, }, - #[strum(message = "Input should be a multiple of {multiple_of}")] MultipleOf { multiple_of: Number, }, - #[strum(message = "Input should be a finite number")] FiniteNumber, // --------------------- // generic length errors - used for everything with a length except strings and bytes which need custom messages - #[strum( - message = "{field_type} should have at least {min_length} item{expected_plural} after validation, not {actual_length}" - )] TooShort { field_type: String, min_length: usize, actual_length: usize, }, - #[strum( - message = "{field_type} should have at most {max_length} item{expected_plural} after validation, not {actual_length}" - )] TooLong { field_type: String, max_length: usize, @@ -131,91 +122,66 @@ pub enum ErrorType { }, // --------------------- // generic collection and iteration errors - #[strum(message = "Input should be iterable")] IterableType, - #[strum(message = "Error iterating over object, error: {error}")] IterationError { error: String, }, // --------------------- // string errors - #[strum(message = "Input should be a valid string")] StringType, - #[strum(message = "Input should be a string, not an instance of a subclass of str")] StringSubType, - #[strum(message = "Input should be a valid string, unable to parse raw data as a unicode string")] StringUnicode, - #[strum(message = "String should have at least {min_length} characters")] StringTooShort { min_length: usize, }, - #[strum(message = "String should have at most {max_length} characters")] StringTooLong { max_length: usize, }, - #[strum(message = "String should match pattern '{pattern}'")] StringPatternMismatch { pattern: String, }, // --------------------- // dict errors - #[strum(message = "Input should be a valid dictionary")] DictType, - #[strum(message = "Input should be a valid mapping, error: {error}")] MappingType { error: Cow<'static, str>, }, // --------------------- // list errors - #[strum(message = "Input should be a valid list/array")] ListType, // --------------------- // tuple errors - #[strum(message = "Input should be a valid tuple")] TupleType, // --------------------- // set errors - #[strum(message = "Input should be a valid set")] SetType, // --------------------- // bool errors - #[strum(message = "Input should be a valid boolean")] BoolType, - #[strum(message = "Input should be a valid boolean, unable to interpret input")] BoolParsing, // --------------------- // int errors - #[strum(message = "Input should be a valid integer")] IntType, - #[strum(message = "Input should be a valid integer, unable to parse string as an integer")] IntParsing, - #[strum(message = "Input should be a valid integer, got a number with a fractional part")] IntFromFloat, // --------------------- // float errors - #[strum(message = "Input should be a valid number")] FloatType, - #[strum(message = "Input should be a valid number, unable to parse string as an number")] FloatParsing, // --------------------- // bytes errors - #[strum(message = "Input should be a valid bytes")] BytesType, - #[strum(message = "Data should have at least {min_length} bytes")] BytesTooShort { min_length: usize, }, - #[strum(message = "Data should have at most {max_length} bytes")] BytesTooLong { max_length: usize, }, // --------------------- // python errors from functions - #[strum(message = "Value error, {error}")] ValueError { error: String, }, - #[strum(message = "Assertion failed, {error}")] AssertionError { error: String, }, @@ -225,145 +191,105 @@ pub enum ErrorType { }, // --------------------- // literals - #[strum(message = "Input should be {expected}")] LiteralError { expected: String, }, // --------------------- // date errors - #[strum(message = "Input should be a valid date")] DateType, - #[strum(message = "Input should be a valid date in the format YYYY-MM-DD, {error}")] DateParsing { error: Cow<'static, str>, }, - #[strum(message = "Input should be a valid date or datetime, {error}")] DateFromDatetimeParsing { error: String, }, - #[strum(message = "Datetimes provided to dates should have zero time - e.g. be exact dates")] DateFromDatetimeInexact, - #[strum(message = "Date should be in the past")] DatePast, - #[strum(message = "Date should be in the future")] DateFuture, // --------------------- // date errors - #[strum(message = "Input should be a valid time")] TimeType, - #[strum(message = "Input should be in a valid time format, {error}")] TimeParsing { error: Cow<'static, str>, }, // --------------------- // datetime errors - #[strum(message = "Input should be a valid datetime")] DatetimeType, - #[strum(message = "Input should be a valid datetime, {error}")] DatetimeParsing { error: Cow<'static, str>, }, - #[strum(message = "Invalid datetime object, got {error}")] DatetimeObjectInvalid { error: String, }, - #[strum(message = "Datetime should be in the past")] DatetimePast, - #[strum(message = "Datetime should be in the future")] DatetimeFuture, - #[strum(message = "Datetime should have timezone info")] DatetimeAware, - #[strum(message = "Datetime should not have timezone info")] DatetimeNaive, // --------------------- // timedelta errors - #[strum(message = "Input should be a valid timedelta")] TimeDeltaType, - #[strum(message = "Input should be a valid timedelta, {error}")] TimeDeltaParsing { error: Cow<'static, str>, }, // --------------------- // frozenset errors - #[strum(message = "Input should be a valid frozenset")] FrozenSetType, // --------------------- // introspection types - e.g. isinstance, callable - #[strum(message = "Input should be an instance of {class}")] IsInstanceOf { class: String, }, - #[strum(message = "Input should be a subclass of {class}")] IsSubclassOf { class: String, }, - #[strum(message = "Input should be callable")] CallableType, // --------------------- // union errors - #[strum( - message = "Input tag '{tag}' found using {discriminator} does not match any of the expected tags: {expected_tags}" - )] UnionTagInvalid { discriminator: String, tag: String, expected_tags: String, }, - #[strum(message = "Unable to extract tag using discriminator {discriminator}")] UnionTagNotFound { discriminator: String, }, // --------------------- // argument errors - #[strum(message = "Arguments must be a tuple, list or a dictionary")] ArgumentsType, - #[strum(message = "Positional arguments must be a list or tuple")] PositionalArgumentsType, - #[strum(message = "Keyword arguments must be a dictionary")] KeywordArgumentsType, - #[strum(message = "Unexpected keyword argument")] UnexpectedKeywordArgument, - #[strum(message = "Missing required keyword argument")] MissingKeywordArgument, - #[strum(message = "Unexpected positional argument")] UnexpectedPositionalArgument, - #[strum(message = "Missing required positional argument")] MissingPositionalArgument, - #[strum(message = "Got multiple values for argument")] MultipleArgumentValues, // --------------------- // dataclass errors (we don't talk about ArgsKwargs here for simplicity) - #[strum(message = "Input should be a dictionary or an instance of {dataclass_name}")] DataclassType { dataclass_name: String, }, // --------------------- // URL errors - #[strum(message = "URL input should be a string or URL")] UrlType, - #[strum(message = "Input should be a valid URL, {error}")] UrlParsing { // would be great if this could be a static cow, waiting for https://github.com/servo/rust-url/issues/801 error: String, }, - #[strum(message = "Input violated strict URL syntax rules, {error}")] UrlSyntaxViolation { error: Cow<'static, str>, }, - #[strum(message = "URL should have at most {max_length} characters")] UrlTooLong { max_length: usize, }, - #[strum(message = "URL scheme should be {expected_schemes}")] UrlScheme { expected_schemes: String, }, } macro_rules! render { - ($error_type:ident, $($value:ident),* $(,)?) => { + ($template:ident, $($value:ident),* $(,)?) => { Ok( - $error_type.message_template() + $template $( .replace(concat!("{", stringify!($value), "}"), $value) )* @@ -372,9 +298,9 @@ macro_rules! render { } macro_rules! to_string_render { - ($error_type:ident, $($value:ident),* $(,)?) => { + ($template:ident, $($value:ident),* $(,)?) => { Ok( - $error_type.message_template() + $template $( .replace(concat!("{", stringify!($value), "}"), &$value.to_string()) )* @@ -506,6 +432,106 @@ impl ErrorType { } } + pub fn message_template_python(&self) -> &'static str { + match self { + Self::NoSuchAttribute {..} => "Object has no attribute '{attribute}'", + Self::JsonInvalid {..} => "Invalid JSON: {error}", + Self::JsonType => "JSON input should be string, bytes or bytearray", + Self::RecursionLoop => "Recursion error - cyclic reference detected", + Self::DictAttributesType => "Input should be a valid dictionary or instance to extract fields from", + Self::Missing => "Field required", + Self::FrozenField => "Field is frozen", + Self::FrozenInstance => "Instance is frozen", + Self::ExtraForbidden => "Extra inputs are not permitted", + Self::InvalidKey => "Keys should be strings", + Self::GetAttributeError {..} => "Error extracting attribute: {error}", + Self::ModelClassType {..} => "Input should be an instance of {class_name}", + Self::NoneRequired => "Input should be None", + Self::Bool => "Input should be a valid boolean", + Self::GreaterThan {..} => "Input should be greater than {gt}", + Self::GreaterThanEqual {..} => "Input should be greater than or equal to {ge}", + Self::LessThan {..} => "Input should be less than {lt}", + Self::LessThanEqual {..} => "Input should be less than or equal to {le}", + Self::MultipleOf {..} => "Input should be a multiple of {multiple_of}", + Self::FiniteNumber => "Input should be a finite number", + Self::TooShort {..} => "{field_type} should have at least {min_length} item{expected_plural} after validation, not {actual_length}", + Self::TooLong {..} => "{field_type} should have at most {max_length} item{expected_plural} after validation, not {actual_length}", + Self::IterableType => "Input should be iterable", + Self::IterationError {..} => "Error iterating over object, error: {error}", + Self::StringType => "Input should be a valid string", + Self::StringSubType => "Input should be a string, not an instance of a subclass of str", + Self::StringUnicode => "Input should be a valid string, unable to parse raw data as a unicode string", + Self::StringTooShort {..} => "String should have at least {min_length} characters", + Self::StringTooLong {..} => "String should have at most {max_length} characters", + Self::StringPatternMismatch {..} => "String should match pattern '{pattern}'", + Self::DictType => "Input should be a valid dictionary", + Self::MappingType {..} => "Input should be a valid mapping, error: {error}", + Self::ListType => "Input should be a valid list", + Self::TupleType => "Input should be a valid tuple", + Self::SetType => "Input should be a valid set", + Self::BoolType => "Input should be a valid boolean", + Self::BoolParsing => "Input should be a valid boolean, unable to interpret input", + Self::IntType => "Input should be a valid integer", + Self::IntParsing => "Input should be a valid integer, unable to parse string as an integer", + Self::IntFromFloat => "Input should be a valid integer, got a number with a fractional part", + Self::FloatType => "Input should be a valid number", + Self::FloatParsing => "Input should be a valid number, unable to parse string as an number", + Self::BytesType => "Input should be a valid bytes", + Self::BytesTooShort {..} => "Data should have at least {min_length} bytes", + Self::BytesTooLong {..} => "Data should have at most {max_length} bytes", + Self::ValueError {..} => "Value error, {error}", + Self::AssertionError {..} => "Assertion failed, {error}", + Self::CustomError {..} => "", // custom errors are handled separately + Self::LiteralError {..} => "Input should be {expected}", + Self::DateType => "Input should be a valid date", + Self::DateParsing {..} => "Input should be a valid date in the format YYYY-MM-DD, {error}", + Self::DateFromDatetimeParsing {..} => "Input should be a valid date or datetime, {error}", + Self::DateFromDatetimeInexact => "Datetimes provided to dates should have zero time - e.g. be exact dates", + Self::DatePast => "Date should be in the past", + Self::DateFuture => "Date should be in the future", + Self::TimeType => "Input should be a valid time", + Self::TimeParsing {..} => "Input should be in a valid time format, {error}", + Self::DatetimeType => "Input should be a valid datetime", + Self::DatetimeParsing {..} => "Input should be a valid datetime, {error}", + Self::DatetimeObjectInvalid {..} => "Invalid datetime object, got {error}", + Self::DatetimePast => "Datetime should be in the past", + Self::DatetimeFuture => "Datetime should be in the future", + Self::DatetimeAware => "Datetime should have timezone info", + Self::DatetimeNaive => "Datetime should not have timezone info", + Self::TimeDeltaType => "Input should be a valid timedelta", + Self::TimeDeltaParsing {..} => "Input should be a valid timedelta, {error}", + Self::FrozenSetType => "Input should be a valid frozenset", + Self::IsInstanceOf {..} => "Input should be an instance of {class}", + Self::IsSubclassOf {..} => "Input should be a subclass of {class}", + Self::CallableType => "Input should be callable", + Self::UnionTagInvalid {..} => "Input tag '{tag}' found using {discriminator} does not match any of the expected tags: {expected_tags}", + Self::UnionTagNotFound {..} => "Unable to extract tag using discriminator {discriminator}", + Self::ArgumentsType => "Arguments must be a tuple, list or a dictionary", + Self::PositionalArgumentsType => "Positional arguments must be a list or tuple", + Self::KeywordArgumentsType => "Keyword arguments must be a dictionary", + Self::UnexpectedKeywordArgument => "Unexpected keyword argument", + Self::MissingKeywordArgument => "Missing required keyword argument", + Self::UnexpectedPositionalArgument => "Unexpected positional argument", + Self::MissingPositionalArgument => "Missing required positional argument", + Self::MultipleArgumentValues => "Got multiple values for argument", + Self::DataclassType {..} => "Input should be a dictionary or an instance of {dataclass_name}", + Self::UrlType => "URL input should be a string or URL", + Self::UrlParsing {..} => "Input should be a valid URL, {error}", + Self::UrlSyntaxViolation {..} => "Input violated strict URL syntax rules, {error}", + Self::UrlTooLong {..} => "URL should have at most {max_length} characters", + Self::UrlScheme {..} => "URL scheme should be {expected_schemes}", + } + } + + pub fn message_template_json(&self) -> &'static str { + match self { + Self::NoneRequired => "Input should be null", + Self::ListType => "Input should be a valid array", + Self::DataclassType { .. } => "Input should be an object", + _ => self.message_template_python(), + } + } + pub fn valid_type(py: Python, error_type: &str) -> bool { let lookup = ERROR_TYPE_LOOKUP.get_or_init(py, Self::build_lookup); lookup.contains_key(error_type) @@ -521,10 +547,6 @@ impl ErrorType { lookup } - pub fn message_template(&self) -> &'static str { - self.get_message().expect("ErrorType with no strum message") - } - pub fn type_string(&self) -> String { match self { Self::CustomError { value_error } => value_error.error_type(), @@ -532,24 +554,28 @@ impl ErrorType { } } - pub fn render_message(&self, py: Python) -> PyResult { + 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(), + }; match self { - Self::NoSuchAttribute { attribute } => render!(self, attribute), - Self::JsonInvalid { error } => render!(self, error), - Self::GetAttributeError { error } => render!(self, error), - Self::ModelClassType { class_name } => render!(self, class_name), - Self::GreaterThan { gt } => to_string_render!(self, gt), - Self::GreaterThanEqual { ge } => to_string_render!(self, ge), - Self::LessThan { lt } => to_string_render!(self, lt), - Self::LessThanEqual { le } => to_string_render!(self, le), - Self::MultipleOf { multiple_of } => to_string_render!(self, multiple_of), + Self::NoSuchAttribute { attribute } => render!(tmpl, attribute), + Self::JsonInvalid { error } => render!(tmpl, error), + Self::GetAttributeError { error } => render!(tmpl, error), + Self::ModelClassType { class_name } => render!(tmpl, class_name), + Self::GreaterThan { gt } => to_string_render!(tmpl, gt), + Self::GreaterThanEqual { ge } => to_string_render!(tmpl, ge), + Self::LessThan { lt } => to_string_render!(tmpl, lt), + Self::LessThanEqual { le } => to_string_render!(tmpl, le), + Self::MultipleOf { multiple_of } => to_string_render!(tmpl, multiple_of), Self::TooShort { field_type, min_length, actual_length, } => { let expected_plural = plural_s(min_length); - to_string_render!(self, field_type, min_length, actual_length, expected_plural) + to_string_render!(tmpl, field_type, min_length, actual_length, expected_plural) } Self::TooLong { field_type, @@ -557,39 +583,39 @@ impl ErrorType { actual_length, } => { let expected_plural = plural_s(max_length); - to_string_render!(self, field_type, max_length, actual_length, expected_plural) + to_string_render!(tmpl, field_type, max_length, actual_length, expected_plural) } - Self::IterationError { error } => render!(self, error), - 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::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), - Self::AssertionError { error } => render!(self, error), + Self::IterationError { error } => render!(tmpl, error), + Self::StringTooShort { min_length } => to_string_render!(tmpl, min_length), + Self::StringTooLong { max_length } => to_string_render!(tmpl, max_length), + Self::StringPatternMismatch { pattern } => render!(tmpl, pattern), + Self::MappingType { error } => render!(tmpl, error), + Self::BytesTooShort { min_length } => to_string_render!(tmpl, min_length), + Self::BytesTooLong { max_length } => to_string_render!(tmpl, max_length), + Self::ValueError { error } => render!(tmpl, error), + Self::AssertionError { error } => render!(tmpl, error), Self::CustomError { value_error } => value_error.message(py), - Self::LiteralError { expected } => render!(self, expected), - Self::DateParsing { error } => render!(self, error), - Self::DateFromDatetimeParsing { error } => render!(self, error), - Self::TimeParsing { error } => render!(self, error), - Self::DatetimeParsing { error } => render!(self, error), - Self::DatetimeObjectInvalid { error } => render!(self, error), - Self::TimeDeltaParsing { error } => render!(self, error), - Self::IsInstanceOf { class } => render!(self, class), - Self::IsSubclassOf { class } => render!(self, class), + Self::LiteralError { expected } => render!(tmpl, expected), + Self::DateParsing { error } => render!(tmpl, error), + Self::DateFromDatetimeParsing { error } => render!(tmpl, error), + Self::TimeParsing { error } => render!(tmpl, error), + Self::DatetimeParsing { error } => render!(tmpl, error), + Self::DatetimeObjectInvalid { error } => render!(tmpl, error), + Self::TimeDeltaParsing { error } => render!(tmpl, error), + Self::IsInstanceOf { class } => render!(tmpl, class), + Self::IsSubclassOf { class } => render!(tmpl, class), Self::UnionTagInvalid { discriminator, tag, expected_tags, - } => render!(self, discriminator, tag, expected_tags), - Self::UnionTagNotFound { discriminator } => render!(self, discriminator), - Self::DataclassType { dataclass_name } => render!(self, dataclass_name), - Self::UrlParsing { error } => render!(self, error), - Self::UrlSyntaxViolation { error } => render!(self, error), - Self::UrlTooLong { max_length } => to_string_render!(self, max_length), - Self::UrlScheme { expected_schemes } => render!(self, expected_schemes), - _ => Ok(self.message_template().to_string()), + } => render!(tmpl, discriminator, tag, expected_tags), + Self::UnionTagNotFound { discriminator } => render!(tmpl, discriminator), + Self::DataclassType { dataclass_name } => render!(tmpl, dataclass_name), + Self::UrlParsing { error } => render!(tmpl, error), + Self::UrlSyntaxViolation { error } => render!(tmpl, error), + Self::UrlTooLong { max_length } => to_string_render!(tmpl, max_length), + Self::UrlScheme { expected_schemes } => render!(tmpl, expected_schemes), + _ => Ok(tmpl.to_string()), } } diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index f5b4027a4..a4716fd19 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -17,7 +17,7 @@ use crate::serializers::GeneralSerializeContext; use super::line_error::ValLineError; use super::location::Location; -use super::types::ErrorType; +use super::types::{ErrorMode, ErrorType}; use super::ValError; #[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core")] @@ -25,15 +25,26 @@ use super::ValError; #[cfg_attr(debug_assertions, derive(Debug))] pub struct ValidationError { line_errors: Vec, + error_mode: ErrorMode, title: PyObject, } impl ValidationError { - pub fn new(line_errors: Vec, title: PyObject) -> Self { - Self { line_errors, title } + pub fn new(line_errors: Vec, title: PyObject, error_mode: ErrorMode) -> Self { + Self { + line_errors, + title, + error_mode, + } } - pub fn from_val_error(py: Python, title: PyObject, error: ValError, outer_location: Option) -> PyErr { + pub fn from_val_error( + py: Python, + title: PyObject, + error_mode: ErrorMode, + error: ValError, + outer_location: Option, + ) -> PyErr { match error { ValError::LineErrors(raw_errors) => { let line_errors: Vec = match outer_location { @@ -43,7 +54,11 @@ impl ValidationError { .collect(), None => raw_errors.into_iter().map(|e| e.into_py(py)).collect(), }; - PyErr::new::((line_errors, title)) + let validation_error = Self::new(line_errors, title, error_mode); + match Py::new(py, validation_error) { + Ok(err) => PyErr::from_value(err.into_ref(py)), + Err(err) => err, + } } ValError::InternalErr(err) => err, ValError::Omit => Self::omit_error(), @@ -51,7 +66,7 @@ impl ValidationError { } pub fn display(&self, py: Python, prefix_override: Option<&'static str>) -> String { - let line_errors = pretty_py_line_errors(py, self.line_errors.iter()); + let line_errors = pretty_py_line_errors(py, &self.error_mode, self.line_errors.iter()); if let Some(prefix) = prefix_override { format!("{prefix}\n{line_errors}") } else { @@ -81,8 +96,12 @@ impl<'a> IntoPy> for ValidationError { #[pymethods] impl ValidationError { #[new] - fn py_new(line_errors: Vec, title: PyObject) -> Self { - Self { line_errors, title } + fn py_new(line_errors: Vec, title: PyObject, error_mode: Option<&str>) -> PyResult { + Ok(Self { + line_errors, + title, + error_mode: ErrorMode::from_raw(error_mode)?, + }) } #[getter] @@ -106,7 +125,7 @@ impl ValidationError { let list: Py = Py::from_owned_ptr(py, ptr); for (index, line_error) in (0_isize..).zip(&self.line_errors) { - let item = line_error.as_dict(py, include_context)?; + let item = line_error.as_dict(py, include_context, &self.error_mode)?; ffi::PyList_SET_ITEM(ptr, index, item.into_ptr()); } @@ -127,6 +146,7 @@ impl ValidationError { line_errors: &self.line_errors, include_context: include_context.unwrap_or(true), extra: &extra, + error_mode: &self.error_mode, }; let writer: Vec = Vec::with_capacity(self.line_errors.len() * 200); @@ -172,9 +192,13 @@ macro_rules! truncate_input_value { }; } -pub fn pretty_py_line_errors<'a>(py: Python, line_errors_iter: impl Iterator) -> String { +pub fn pretty_py_line_errors<'a>( + py: Python, + error_mode: &ErrorMode, + line_errors_iter: impl Iterator, +) -> String { line_errors_iter - .map(|i| i.pretty(py)) + .map(|i| i.pretty(py, error_mode)) .collect::, _>>() .unwrap_or_else(|err| vec![format!("[error formatting line errors: {err}]")]) .join("\n") @@ -212,11 +236,11 @@ impl<'a> IntoPy> for PyLineError { } impl PyLineError { - pub fn as_dict(&self, py: Python, include_context: Option) -> PyResult { + pub fn as_dict(&self, py: Python, include_context: Option, error_mode: &ErrorMode) -> 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)?)?; + dict.set_item("msg", self.error_type.render_message(py, error_mode)?)?; dict.set_item("input", &self.input_value)?; if include_context.unwrap_or(true) { if let Some(context) = self.error_type.py_dict(py)? { @@ -226,11 +250,11 @@ impl PyLineError { Ok(dict.into_py(py)) } - fn pretty(&self, py: Python) -> Result { + fn pretty(&self, py: Python, error_mode: &ErrorMode) -> Result { let mut output = String::with_capacity(200); write!(output, "{}", self.location)?; - let message = match self.error_type.render_message(py) { + let message = match self.error_type.render_message(py, error_mode) { Ok(message) => message, Err(err) => format!("(error rendering message: {err})"), }; @@ -264,6 +288,7 @@ struct ValidationErrorSerializer<'py> { line_errors: &'py [PyLineError], include_context: bool, extra: &'py crate::serializers::Extra<'py>, + error_mode: &'py ErrorMode, } impl<'py> Serialize for ValidationErrorSerializer<'py> { @@ -278,6 +303,7 @@ impl<'py> Serialize for ValidationErrorSerializer<'py> { line_error, include_context: self.include_context, extra: self.extra, + error_mode: self.error_mode, }; seq.serialize_element(&line_s)?; } @@ -290,6 +316,7 @@ struct PyLineErrorSerializer<'py> { line_error: &'py PyLineError, include_context: bool, extra: &'py crate::serializers::Extra<'py>, + error_mode: &'py ErrorMode, } impl<'py> Serialize for PyLineErrorSerializer<'py> { @@ -308,7 +335,7 @@ impl<'py> Serialize for PyLineErrorSerializer<'py> { let msg = self .line_error .error_type - .render_message(py) + .render_message(py, self.error_mode) .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 59f5d6b43..b1200740d 100644 --- a/src/errors/value_exception.rs +++ b/src/errors/value_exception.rs @@ -1,3 +1,4 @@ +use crate::errors::ErrorMode; use pyo3::exceptions::{PyException, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyString}; @@ -125,7 +126,7 @@ impl PydanticKnownError { #[getter] pub fn message_template(&self) -> &'static str { - self.error_type.message_template() + self.error_type.message_template_python() } #[getter] @@ -134,7 +135,7 @@ impl PydanticKnownError { } pub fn message(&self, py: Python) -> PyResult { - self.error_type.render_message(py) + self.error_type.render_message(py, &ErrorMode::Python) } fn __str__(&self, py: Python) -> PyResult { diff --git a/src/validators/generator.rs b/src/validators/generator.rs index cdf8bf8f5..37bce0d8f 100644 --- a/src/validators/generator.rs +++ b/src/validators/generator.rs @@ -4,7 +4,7 @@ use pyo3::prelude::*; use pyo3::types::PyDict; use crate::build_tools::SchemaDict; -use crate::errors::{ErrorType, LocItem, ValError, ValResult}; +use crate::errors::{ErrorMode, ErrorType, LocItem, ValError, ValResult}; use crate::input::{GenericIterator, Input}; use crate::questions::Question; use crate::recursion_guard::RecursionGuard; @@ -126,6 +126,7 @@ impl ValidatorIterator { return Err(ValidationError::from_val_error( py, "ValidatorIterator".to_object(py), + ErrorMode::Python, val_error, None, )); @@ -149,6 +150,7 @@ impl ValidatorIterator { return Err(ValidationError::from_val_error( py, "ValidatorIterator".to_object(py), + ErrorMode::Python, val_error, None, )); @@ -250,7 +252,9 @@ impl InternalValidator { &self.slots, &mut self.recursion_guard, ) - .map_err(|e| ValidationError::from_val_error(py, self.name.to_object(py), e, outer_location)) + .map_err(|e| { + ValidationError::from_val_error(py, self.name.to_object(py), ErrorMode::Python, e, outer_location) + }) } pub fn validate<'s, 'data>( @@ -271,6 +275,8 @@ impl InternalValidator { }; self.validator .validate(py, input, &extra, &self.slots, &mut self.recursion_guard) - .map_err(|e| ValidationError::from_val_error(py, self.name.to_object(py), e, outer_location)) + .map_err(|e| { + ValidationError::from_val_error(py, self.name.to_object(py), ErrorMode::Python, e, outer_location) + }) } } diff --git a/src/validators/mod.rs b/src/validators/mod.rs index cd17e356a..8305a52c4 100644 --- a/src/validators/mod.rs +++ b/src/validators/mod.rs @@ -10,7 +10,7 @@ use pyo3::{intern, PyTraverseError, PyVisit}; use crate::build_context::BuildContext; use crate::build_tools::{py_err, py_error_type, SchemaDict, SchemaError}; -use crate::errors::{LocItem, ValError, ValResult, ValidationError}; +use crate::errors::{ErrorMode, LocItem, ValError, ValResult, ValidationError}; use crate::input::Input; use crate::questions::{Answers, Question}; use crate::recursion_guard::RecursionGuard; @@ -108,7 +108,7 @@ impl SchemaValidator { self_instance: Option<&PyAny>, ) -> PyResult { let r = self._validate(py, input, strict, context, self_instance); - r.map_err(|e| self.prepare_validation_err(py, e)) + r.map_err(|e| self.prepare_validation_err(py, e, ErrorMode::Python)) } #[pyo3(signature = (input, *, strict=None, context=None, self_instance=None))] @@ -140,9 +140,9 @@ impl SchemaValidator { match input.parse_json() { Ok(input) => { let r = self._validate(py, &input, strict, context, self_instance); - r.map_err(|e| self.prepare_validation_err(py, e)) + r.map_err(|e| self.prepare_validation_err(py, e, ErrorMode::Json)) } - Err(err) => Err(self.prepare_validation_err(py, err)), + Err(err) => Err(self.prepare_validation_err(py, err, ErrorMode::Json)), } } @@ -187,7 +187,7 @@ impl SchemaValidator { let guard = &mut RecursionGuard::default(); self.validator .validate_assignment(py, obj, field_name, field_value, &extra, &self.slots, guard) - .map_err(|e| self.prepare_validation_err(py, e)) + .map_err(|e| self.prepare_validation_err(py, e, ErrorMode::Python)) } pub fn __repr__(&self, py: Python) -> String { @@ -237,8 +237,8 @@ impl SchemaValidator { ) } - fn prepare_validation_err(&self, py: Python, error: ValError) -> PyErr { - ValidationError::from_val_error(py, self.title.clone_ref(py), error, None) + fn prepare_validation_err(&self, py: Python, error: ValError, error_mode: ErrorMode) -> PyErr { + ValidationError::from_val_error(py, self.title.clone_ref(py), error_mode, error, None) } } diff --git a/tests/test_errors.py b/tests/test_errors.py index 2cfe88c9c..4b036fccf 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -188,7 +188,7 @@ def f(input_value, info): ('invalid_key', 'Keys should be strings', None), ('get_attribute_error', 'Error extracting attribute: foo', {'error': 'foo'}), ('model_class_type', 'Input should be an instance of foo', {'class_name': 'foo'}), - ('none_required', 'Input should be None/null', None), + ('none_required', 'Input should be None', None), ('bool', 'Input should be a valid boolean', None), ('greater_than', 'Input should be greater than 42.1', {'gt': 42.1}), ('greater_than', 'Input should be greater than 42.1', {'gt': '42.1'}), @@ -217,7 +217,7 @@ def f(input_value, info): ('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), + ('list_type', 'Input should be a valid list', None), ('tuple_type', 'Input should be a valid tuple', None), ('set_type', 'Input should be a valid set', None), ('bool_type', 'Input should be a valid boolean', None), diff --git a/tests/validators/test_dataclasses.py b/tests/validators/test_dataclasses.py index 1b3ab06a2..9d047e1da 100644 --- a/tests/validators/test_dataclasses.py +++ b/tests/validators/test_dataclasses.py @@ -2,7 +2,7 @@ import re import pytest -from dirty_equals import IsListOrTuple +from dirty_equals import IsListOrTuple, IsStr from pydantic_core import ArgsKwargs, SchemaValidator, ValidationError, core_schema @@ -159,12 +159,12 @@ def test_dataclass_args_init_only(py_and_json: PyAndJson, input_value, expected) ( ('hello',), Err( - 'Input should be a dictionary or an instance of MyDataclass', + 'Input should be (an object|a dictionary or an instance of MyDataclass)', errors=[ { 'type': 'dataclass_type', 'loc': (), - 'msg': 'Input should be a dictionary or an instance of MyDataclass', + 'msg': IsStr(regex='Input should be (an object|a dictionary or an instance of MyDataclass)'), 'input': IsListOrTuple('hello'), 'ctx': {'dataclass_name': 'MyDataclass'}, } @@ -180,7 +180,7 @@ def test_dataclass_args_init_only_no_fields(py_and_json: PyAndJson, input_value, v = py_and_json(schema) if isinstance(expected, Err): - with pytest.raises(ValidationError, match=re.escape(expected.message)) as exc_info: + with pytest.raises(ValidationError, match=expected.message) as exc_info: v.validate_test(input_value) # debug(exc_info.value.errors()) diff --git a/tests/validators/test_definitions_recursive.py b/tests/validators/test_definitions_recursive.py index af9eb21c0..30b9de986 100644 --- a/tests/validators/test_definitions_recursive.py +++ b/tests/validators/test_definitions_recursive.py @@ -127,7 +127,7 @@ def test_nullable_error(): { 'type': 'none_required', 'loc': ('sub_branch', 'none'), - 'msg': 'Input should be None/null', + 'msg': 'Input should be None', 'input': {'width': 'wrong'}, }, { diff --git a/tests/validators/test_float.py b/tests/validators/test_float.py index 408f2bc8c..023d6a60d 100644 --- a/tests/validators/test_float.py +++ b/tests/validators/test_float.py @@ -4,7 +4,7 @@ from typing import Any, Dict import pytest -from dirty_equals import FunctionCheck +from dirty_equals import FunctionCheck, IsStr from pydantic_core import SchemaValidator, ValidationError @@ -164,7 +164,12 @@ def test_union_float_simple(py_and_json: PyAndJson): 'msg': 'Input should be a valid number, unable to parse string as an number', 'input': 'xxx', }, - {'type': 'list_type', 'loc': ('list[any]',), 'msg': 'Input should be a valid list/array', 'input': 'xxx'}, + { + 'type': 'list_type', + 'loc': ('list[any]',), + 'msg': IsStr(regex='Input should be a valid (list|array)'), + 'input': 'xxx', + }, ] diff --git a/tests/validators/test_int.py b/tests/validators/test_int.py index 19ea7bdf6..1a3a4322d 100644 --- a/tests/validators/test_int.py +++ b/tests/validators/test_int.py @@ -3,6 +3,7 @@ from typing import Any, Dict import pytest +from dirty_equals import IsStr from pydantic_core import SchemaValidator, ValidationError @@ -193,7 +194,12 @@ def test_union_int_simple(py_and_json: PyAndJson): 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'xxx', }, - {'type': 'list_type', 'loc': ('list[any]',), 'msg': 'Input should be a valid list/array', 'input': 'xxx'}, + { + 'type': 'list_type', + 'loc': ('list[any]',), + 'msg': IsStr(regex='Input should be a valid (list|array)'), + 'input': 'xxx', + }, ] diff --git a/tests/validators/test_json.py b/tests/validators/test_json.py index c94241cd2..7fd5b74b3 100644 --- a/tests/validators/test_json.py +++ b/tests/validators/test_json.py @@ -88,12 +88,12 @@ def test_any_python(input_value, expected): [ ('[1]', [1]), ('[1, 2, 3, "4"]', [1, 2, 3, 4]), - ('44', Err('Input should be a valid list/array [type=list_type, input_value=44, input_type=int')), - ('"x"', Err("Input should be a valid list/array [type=list_type, input_value='x', input_type=str")), + ('44', Err(r'Input should be a valid (list|array) \[type=list_type, input_value=44, input_type=int')), + ('"x"', Err(r"Input should be a valid (list|array) \[type=list_type, input_value='x', input_type=str")), ( '[1, 2, 3, "err"]', Err( - 'Input should be a valid integer, unable to parse string as an integer [type=int_parsing,', + r'Input should be a valid integer, unable to parse string as an integer \[type=int_parsing,', [ { 'type': 'int_parsing', @@ -109,7 +109,7 @@ def test_any_python(input_value, expected): def test_list_int(py_and_json: PyAndJson, input_value, expected): v = py_and_json(core_schema.json_schema(core_schema.list_schema(core_schema.int_schema()))) if isinstance(expected, Err): - with pytest.raises(ValidationError, match=re.escape(expected.message)) as exc_info: + with pytest.raises(ValidationError, match=expected.message) as exc_info: v.validate_test(input_value) if expected.errors is not None: diff --git a/tests/validators/test_list.py b/tests/validators/test_list.py index aaf8c10fa..463745a4a 100644 --- a/tests/validators/test_list.py +++ b/tests/validators/test_list.py @@ -16,15 +16,15 @@ [ ([1, 2, 3], [1, 2, 3]), ([1, 2, '3'], [1, 2, 3]), - (5, Err('Input should be a valid list/array [type=list_type, input_value=5, input_type=int]')), - ('5', Err("Input should be a valid list/array [type=list_type, input_value='5', input_type=str]")), + (5, Err(r'Input should be a valid (list|array) \[type=list_type, input_value=5, input_type=int\]')), + ('5', Err(r"Input should be a valid (list|array) \[type=list_type, input_value='5', input_type=str\]")), ], ids=repr, ) -def test_list_json(py_and_json: PyAndJson, input_value, expected): +def test_list_py_or_json(py_and_json: PyAndJson, input_value, expected): v = py_and_json({'type': 'list', 'items_schema': {'type': 'int'}}) if isinstance(expected, Err): - with pytest.raises(ValidationError, match=re.escape(expected.message)): + with pytest.raises(ValidationError, match=expected.message): v.validate_test(input_value) else: assert v.validate_test(input_value) == expected @@ -36,7 +36,7 @@ def test_list_strict(): with pytest.raises(ValidationError) as exc_info: v.validate_python((1, 2, '33')) assert exc_info.value.errors() == [ - {'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid list/array', 'input': (1, 2, '33')} + {'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid list', 'input': (1, 2, '33')} ] @@ -52,15 +52,15 @@ def gen_ints(): ([1, 2, '3'], [1, 2, 3]), ((1, 2, '3'), [1, 2, 3]), (deque((1, 2, '3')), [1, 2, 3]), - ({1, 2, '3'}, Err('Input should be a valid list/array [type=list_type,')), + ({1, 2, '3'}, Err('Input should be a valid list [type=list_type,')), (gen_ints(), [1, 2, 3]), - (frozenset({1, 2, '3'}), Err('Input should be a valid list/array [type=list_type,')), + (frozenset({1, 2, '3'}), Err('Input should be a valid list [type=list_type,')), ({1: 10, 2: 20, '3': '30'}.keys(), [1, 2, 3]), ({1: 10, 2: 20, '3': '30'}.values(), [10, 20, 30]), - ({1: 10, 2: 20, '3': '30'}, Err('Input should be a valid list/array [type=list_type,')), + ({1: 10, 2: 20, '3': '30'}, Err('Input should be a valid list [type=list_type,')), ((x for x in [1, 2, '3']), [1, 2, 3]), - ('456', Err("Input should be a valid list/array [type=list_type, input_value='456', input_type=str]")), - (b'789', Err("Input should be a valid list/array [type=list_type, input_value=b'789', input_type=bytes]")), + ('456', Err("Input should be a valid list [type=list_type, input_value='456', input_type=str]")), + (b'789', Err("Input should be a valid list [type=list_type, input_value=b'789', input_type=bytes]")), ], ids=repr, ) @@ -73,16 +73,29 @@ def test_list_int(input_value, expected): assert v.validate_python(input_value) == expected +def test_list_json(): + v = SchemaValidator({'type': 'list', 'items_schema': {'type': 'int'}}) + assert v.validate_json('[1, "2", 3]') == [1, 2, 3] + + with pytest.raises(ValidationError) as exc_info: + v.validate_json('1') + + # insert_assert(exc_info.value.errors()) + assert exc_info.value.errors() == [ + {'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid array', 'input': 1} + ] + + @pytest.mark.parametrize( 'input_value,expected', [ ([], []), ([1, '2', b'3'], [1, '2', b'3']), - (frozenset([1, '2', b'3']), Err('Input should be a valid list/array [type=list_type,')), + (frozenset([1, '2', b'3']), Err('Input should be a valid list [type=list_type,')), ((), []), ((1, '2', b'3'), [1, '2', b'3']), (deque([1, '2', b'3']), [1, '2', b'3']), - ({1, '2', b'3'}, Err('Input should be a valid list/array [type=list_type,')), + ({1, '2', b'3'}, Err('Input should be a valid list [type=list_type,')), ], ) def test_list_any(input_value, expected): @@ -315,7 +328,7 @@ def test_sequence(MySequence): v.validate_python(MySequence()) # insert_assert(exc_info.value.errors()) assert exc_info.value.errors() == [ - {'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid list/array', 'input': IsInstance(MySequence)} + {'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid list', 'input': IsInstance(MySequence)} ] @@ -332,7 +345,7 @@ def test_sequence(MySequence): 123, Err( '1 validation error for list[int]', - [{'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid list/array', 'input': 123}], + [{'type': 'list_type', 'loc': (), 'msg': 'Input should be a valid list', 'input': 123}], ), ), ], diff --git a/tests/validators/test_none.py b/tests/validators/test_none.py index 8814ec5ec..62c58e956 100644 --- a/tests/validators/test_none.py +++ b/tests/validators/test_none.py @@ -1,15 +1,19 @@ import pytest -from pydantic_core import ValidationError +from pydantic_core import SchemaValidator, ValidationError -from ..conftest import PyAndJson + +def test_python_none(): + v = SchemaValidator({'type': 'none'}) + assert v.validate_python(None) is None + with pytest.raises(ValidationError) as exc_info: + v.validate_python(1) + assert exc_info.value.errors() == [{'type': 'none_required', 'loc': (), 'msg': 'Input should be None', 'input': 1}] -def test_none(py_and_json: PyAndJson): - v = py_and_json({'type': 'none'}) - assert v.validate_test(None) is None +def test_json_none(): + v = SchemaValidator({'type': 'none'}) + assert v.validate_json('null') is None with pytest.raises(ValidationError) as exc_info: - v.validate_test(1) - assert exc_info.value.errors() == [ - {'type': 'none_required', 'loc': (), 'msg': 'Input should be None/null', 'input': 1} - ] + v.validate_json('1') + assert exc_info.value.errors() == [{'type': 'none_required', 'loc': (), 'msg': 'Input should be null', 'input': 1}] diff --git a/tests/validators/test_union.py b/tests/validators/test_union.py index e29ecdae2..402023402 100644 --- a/tests/validators/test_union.py +++ b/tests/validators/test_union.py @@ -194,7 +194,7 @@ def test_nullable_via_union(): with pytest.raises(ValidationError) as exc_info: v.validate_python('hello') assert exc_info.value.errors() == [ - {'type': 'none_required', 'loc': ('none',), 'msg': 'Input should be None/null', 'input': 'hello'}, + {'type': 'none_required', 'loc': ('none',), 'msg': 'Input should be None', 'input': 'hello'}, { 'type': 'int_parsing', 'loc': ('int',),