diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 0ba056555..ef45a9793 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -4236,6 +4236,7 @@ def definition_reference_schema( 'model_attributes_type', 'dataclass_type', 'dataclass_exact_type', + 'default_factory_not_called', 'none_required', 'greater_than', 'greater_than_equal', diff --git a/src/errors/types.rs b/src/errors/types.rs index 3dc18565a..922c9bfeb 100644 --- a/src/errors/types.rs +++ b/src/errors/types.rs @@ -196,6 +196,9 @@ error_types! { class_name: {ctx_type: String, ctx_fn: field_from_context}, }, // --------------------- + // Default factory not called (happens when there's already an error and the factory takes data) + DefaultFactoryNotCalled {}, + // --------------------- // None errors NoneRequired {}, // --------------------- @@ -493,6 +496,7 @@ impl ErrorType { Self::ModelAttributesType {..} => "Input should be a valid dictionary or object to extract fields from", Self::DataclassType {..} => "Input should be a dictionary or an instance of {class_name}", Self::DataclassExactType {..} => "Input should be an instance of {class_name}", + Self::DefaultFactoryNotCalled {..} => "The default factory uses validated data, but at least one validation error occurred", Self::NoneRequired {..} => "Input should be None", Self::GreaterThan {..} => "Input should be greater than {gt}", Self::GreaterThanEqual {..} => "Input should be greater than or equal to {ge}", diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index f7ea91db8..461b26b6d 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -528,7 +528,8 @@ impl PyLineError { }; write!(output, " {message} [type={}", self.error_type.type_string())?; - if !hide_input { + // special case: don't show input for DefaultFactoryNotCalled errors - there is no valid input + if !hide_input && !matches!(self.error_type, ErrorType::DefaultFactoryNotCalled { .. }) { let input_value = self.input_value.bind(py); let input_str = safe_repr(input_value); write!(output, ", input_value=")?; diff --git a/src/validators/mod.rs b/src/validators/mod.rs index 7f6a5bdd7..adcf1ba55 100644 --- a/src/validators/mod.rs +++ b/src/validators/mod.rs @@ -668,7 +668,7 @@ fn build_validator_inner( pub struct Extra<'a, 'py> { /// Validation mode pub input_type: InputType, - /// This is used as the `data` kwargs to validator functions + /// This is used as the `data` kwargs to validator functions and default factories (if they accept the argument) pub data: Option>, /// whether we're in strict or lax mode pub strict: Option, diff --git a/src/validators/model_fields.rs b/src/validators/model_fields.rs index 672cd55ce..be8ebb4b9 100644 --- a/src/validators/model_fields.rs +++ b/src/validators/model_fields.rs @@ -210,13 +210,18 @@ impl Validator for ModelFieldsValidator { fields_set_vec.push(field.name_py.clone_ref(py)); fields_set_count += 1; } - 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)); + Err(e) => { + state.has_field_error = true; + match e { + ValError::Omit => continue, + ValError::LineErrors(line_errors) => { + for err in line_errors { + errors.push(lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name)); + } + } + err => return Err(err), } } - Err(err) => return Err(err), } continue; } diff --git a/src/validators/validation_state.rs b/src/validators/validation_state.rs index ed14f2f13..e5ec3a5fc 100644 --- a/src/validators/validation_state.rs +++ b/src/validators/validation_state.rs @@ -26,6 +26,10 @@ pub struct ValidationState<'a, 'py> { pub fields_set_count: Option, // True if `allow_partial=true` and we're validating the last element of a sequence or mapping. pub allow_partial: PartialMode, + // Whether at least one field had a validation error. This is used in the context of structured types + // (models, dataclasses, etc), where we need to know if a validation error occurred before calling + // a default factory that takes the validated data. + pub has_field_error: bool, // deliberately make Extra readonly extra: Extra<'a, 'py>, } @@ -37,6 +41,7 @@ impl<'a, 'py> ValidationState<'a, 'py> { exactness: None, fields_set_count: None, allow_partial, + has_field_error: false, extra, } } diff --git a/src/validators/with_default.rs b/src/validators/with_default.rs index c097f40d0..dc881d5a1 100644 --- a/src/validators/with_default.rs +++ b/src/validators/with_default.rs @@ -11,7 +11,7 @@ use pyo3::PyVisit; use super::{build_validator, BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator}; use crate::build_tools::py_schema_err; use crate::build_tools::schema_or_config_same; -use crate::errors::{LocItem, ValError, ValResult}; +use crate::errors::{ErrorTypeDefaults, LocItem, ValError, ValResult}; use crate::input::Input; use crate::py_gc::PyGcTraverse; use crate::tools::SchemaDict; @@ -182,6 +182,18 @@ impl Validator for WithDefaultValidator { outer_loc: Option>, state: &mut ValidationState<'_, 'py>, ) -> ValResult>> { + if matches!(self.default, DefaultType::DefaultFactory(_, true)) && state.has_field_error { + // The default factory might use data from fields that failed to validate, and this results + // in an unhelpul error. + let mut err = ValError::new( + ErrorTypeDefaults::DefaultFactoryNotCalled, + PydanticUndefinedType::new(py).into_bound(py).into_any(), + ); + if let Some(outer_loc) = outer_loc { + err = err.with_outer_location(outer_loc); + } + return Err(err); + } match self.default.default_value(py, state.extra().data.as_ref())? { Some(stored_dft) => { let dft: Py = if self.copy_default { diff --git a/tests/test_errors.py b/tests/test_errors.py index 8a87489d7..249f18e7e 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -267,6 +267,11 @@ def f(input_value, info): ('model_attributes_type', 'Input should be a valid dictionary or object to extract fields from', None), ('dataclass_exact_type', 'Input should be an instance of Foobar', {'class_name': 'Foobar'}), ('dataclass_type', 'Input should be a dictionary or an instance of Foobar', {'class_name': 'Foobar'}), + ( + 'default_factory_not_called', + 'The default factory uses validated data, but at least one validation error occurred', + None, + ), ('missing', 'Field required', None), ('frozen_field', 'Field is frozen', None), ('frozen_instance', 'Instance is frozen', None), diff --git a/tests/validators/test_with_default.py b/tests/validators/test_with_default.py index c0b8db8f8..443a87fa6 100644 --- a/tests/validators/test_with_default.py +++ b/tests/validators/test_with_default.py @@ -8,6 +8,7 @@ from pydantic_core import ( ArgsKwargs, + PydanticUndefined, PydanticUseDefault, SchemaError, SchemaValidator, @@ -819,3 +820,59 @@ def _raise(ex: Exception) -> None: v.validate_python(input_value) assert exc_info.value.errors(include_url=False, include_context=False) == expected + + +def test_default_factory_not_called_if_existing_error(pydantic_version) -> None: + class Test: + def __init__(self, a: int, b: int): + self.a = a + self.b = b + + schema = core_schema.model_schema( + cls=Test, + schema=core_schema.model_fields_schema( + computed_fields=[], + fields={ + 'a': core_schema.model_field( + schema=core_schema.int_schema(), + ), + 'b': core_schema.model_field( + schema=core_schema.with_default_schema( + schema=core_schema.int_schema(), + default_factory=lambda data: data['a'], + default_factory_takes_data=True, + ), + ), + }, + ), + ) + + v = SchemaValidator(schema) + with pytest.raises(ValidationError) as e: + v.validate_python({'a': 'not_an_int'}) + + assert e.value.errors(include_url=False) == [ + { + 'type': 'int_parsing', + 'loc': ('a',), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not_an_int', + }, + { + 'input': PydanticUndefined, + 'loc': ('b',), + 'msg': 'The default factory uses validated data, but at least one validation error occurred', + 'type': 'default_factory_not_called', + }, + ] + + assert ( + str(e.value) + == f"""2 validation errors for Test +a + Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not_an_int', input_type=str] + For further information visit https://errors.pydantic.dev/{pydantic_version}/v/int_parsing +b + The default factory uses validated data, but at least one validation error occurred [type=default_factory_not_called] + For further information visit https://errors.pydantic.dev/{pydantic_version}/v/default_factory_not_called""" + )