Skip to content

Commit 40050eb

Browse files
authored
Merge branch 'main' into fix-12273-dict
2 parents 7f8e658 + e87ba01 commit 40050eb

File tree

9 files changed

+98
-8
lines changed

9 files changed

+98
-8
lines changed

python/pydantic_core/core_schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4236,6 +4236,7 @@ def definition_reference_schema(
42364236
'model_attributes_type',
42374237
'dataclass_type',
42384238
'dataclass_exact_type',
4239+
'default_factory_not_called',
42394240
'none_required',
42404241
'greater_than',
42414242
'greater_than_equal',

src/errors/types.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,9 @@ error_types! {
196196
class_name: {ctx_type: String, ctx_fn: field_from_context},
197197
},
198198
// ---------------------
199+
// Default factory not called (happens when there's already an error and the factory takes data)
200+
DefaultFactoryNotCalled {},
201+
// ---------------------
199202
// None errors
200203
NoneRequired {},
201204
// ---------------------
@@ -493,6 +496,7 @@ impl ErrorType {
493496
Self::ModelAttributesType {..} => "Input should be a valid dictionary or object to extract fields from",
494497
Self::DataclassType {..} => "Input should be a dictionary or an instance of {class_name}",
495498
Self::DataclassExactType {..} => "Input should be an instance of {class_name}",
499+
Self::DefaultFactoryNotCalled {..} => "The default factory uses validated data, but at least one validation error occurred",
496500
Self::NoneRequired {..} => "Input should be None",
497501
Self::GreaterThan {..} => "Input should be greater than {gt}",
498502
Self::GreaterThanEqual {..} => "Input should be greater than or equal to {ge}",

src/errors/validation_exception.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,8 @@ impl PyLineError {
528528
};
529529
write!(output, " {message} [type={}", self.error_type.type_string())?;
530530

531-
if !hide_input {
531+
// special case: don't show input for DefaultFactoryNotCalled errors - there is no valid input
532+
if !hide_input && !matches!(self.error_type, ErrorType::DefaultFactoryNotCalled { .. }) {
532533
let input_value = self.input_value.bind(py);
533534
let input_str = safe_repr(input_value);
534535
write!(output, ", input_value=")?;

src/validators/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ fn build_validator_inner(
668668
pub struct Extra<'a, 'py> {
669669
/// Validation mode
670670
pub input_type: InputType,
671-
/// This is used as the `data` kwargs to validator functions
671+
/// This is used as the `data` kwargs to validator functions and default factories (if they accept the argument)
672672
pub data: Option<Bound<'py, PyDict>>,
673673
/// whether we're in strict or lax mode
674674
pub strict: Option<bool>,

src/validators/model_fields.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,18 @@ impl Validator for ModelFieldsValidator {
210210
fields_set_vec.push(field.name_py.clone_ref(py));
211211
fields_set_count += 1;
212212
}
213-
Err(ValError::Omit) => continue,
214-
Err(ValError::LineErrors(line_errors)) => {
215-
for err in line_errors {
216-
errors.push(lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name));
213+
Err(e) => {
214+
state.has_field_error = true;
215+
match e {
216+
ValError::Omit => continue,
217+
ValError::LineErrors(line_errors) => {
218+
for err in line_errors {
219+
errors.push(lookup_path.apply_error_loc(err, self.loc_by_alias, &field.name));
220+
}
221+
}
222+
err => return Err(err),
217223
}
218224
}
219-
Err(err) => return Err(err),
220225
}
221226
continue;
222227
}

src/validators/validation_state.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub struct ValidationState<'a, 'py> {
2626
pub fields_set_count: Option<usize>,
2727
// True if `allow_partial=true` and we're validating the last element of a sequence or mapping.
2828
pub allow_partial: PartialMode,
29+
// Whether at least one field had a validation error. This is used in the context of structured types
30+
// (models, dataclasses, etc), where we need to know if a validation error occurred before calling
31+
// a default factory that takes the validated data.
32+
pub has_field_error: bool,
2933
// deliberately make Extra readonly
3034
extra: Extra<'a, 'py>,
3135
}
@@ -37,6 +41,7 @@ impl<'a, 'py> ValidationState<'a, 'py> {
3741
exactness: None,
3842
fields_set_count: None,
3943
allow_partial,
44+
has_field_error: false,
4045
extra,
4146
}
4247
}

src/validators/with_default.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use pyo3::PyVisit;
1111
use super::{build_validator, BuildValidator, CombinedValidator, DefinitionsBuilder, ValidationState, Validator};
1212
use crate::build_tools::py_schema_err;
1313
use crate::build_tools::schema_or_config_same;
14-
use crate::errors::{LocItem, ValError, ValResult};
14+
use crate::errors::{ErrorTypeDefaults, LocItem, ValError, ValResult};
1515
use crate::input::Input;
1616
use crate::py_gc::PyGcTraverse;
1717
use crate::tools::SchemaDict;
@@ -182,6 +182,18 @@ impl Validator for WithDefaultValidator {
182182
outer_loc: Option<impl Into<LocItem>>,
183183
state: &mut ValidationState<'_, 'py>,
184184
) -> ValResult<Option<Py<PyAny>>> {
185+
if matches!(self.default, DefaultType::DefaultFactory(_, true)) && state.has_field_error {
186+
// The default factory might use data from fields that failed to validate, and this results
187+
// in an unhelpul error.
188+
let mut err = ValError::new(
189+
ErrorTypeDefaults::DefaultFactoryNotCalled,
190+
PydanticUndefinedType::new(py).into_bound(py).into_any(),
191+
);
192+
if let Some(outer_loc) = outer_loc {
193+
err = err.with_outer_location(outer_loc);
194+
}
195+
return Err(err);
196+
}
185197
match self.default.default_value(py, state.extra().data.as_ref())? {
186198
Some(stored_dft) => {
187199
let dft: Py<PyAny> = if self.copy_default {

tests/test_errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,11 @@ def f(input_value, info):
267267
('model_attributes_type', 'Input should be a valid dictionary or object to extract fields from', None),
268268
('dataclass_exact_type', 'Input should be an instance of Foobar', {'class_name': 'Foobar'}),
269269
('dataclass_type', 'Input should be a dictionary or an instance of Foobar', {'class_name': 'Foobar'}),
270+
(
271+
'default_factory_not_called',
272+
'The default factory uses validated data, but at least one validation error occurred',
273+
None,
274+
),
270275
('missing', 'Field required', None),
271276
('frozen_field', 'Field is frozen', None),
272277
('frozen_instance', 'Instance is frozen', None),

tests/validators/test_with_default.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from pydantic_core import (
1010
ArgsKwargs,
11+
PydanticUndefined,
1112
PydanticUseDefault,
1213
SchemaError,
1314
SchemaValidator,
@@ -819,3 +820,59 @@ def _raise(ex: Exception) -> None:
819820
v.validate_python(input_value)
820821

821822
assert exc_info.value.errors(include_url=False, include_context=False) == expected
823+
824+
825+
def test_default_factory_not_called_if_existing_error(pydantic_version) -> None:
826+
class Test:
827+
def __init__(self, a: int, b: int):
828+
self.a = a
829+
self.b = b
830+
831+
schema = core_schema.model_schema(
832+
cls=Test,
833+
schema=core_schema.model_fields_schema(
834+
computed_fields=[],
835+
fields={
836+
'a': core_schema.model_field(
837+
schema=core_schema.int_schema(),
838+
),
839+
'b': core_schema.model_field(
840+
schema=core_schema.with_default_schema(
841+
schema=core_schema.int_schema(),
842+
default_factory=lambda data: data['a'],
843+
default_factory_takes_data=True,
844+
),
845+
),
846+
},
847+
),
848+
)
849+
850+
v = SchemaValidator(schema)
851+
with pytest.raises(ValidationError) as e:
852+
v.validate_python({'a': 'not_an_int'})
853+
854+
assert e.value.errors(include_url=False) == [
855+
{
856+
'type': 'int_parsing',
857+
'loc': ('a',),
858+
'msg': 'Input should be a valid integer, unable to parse string as an integer',
859+
'input': 'not_an_int',
860+
},
861+
{
862+
'input': PydanticUndefined,
863+
'loc': ('b',),
864+
'msg': 'The default factory uses validated data, but at least one validation error occurred',
865+
'type': 'default_factory_not_called',
866+
},
867+
]
868+
869+
assert (
870+
str(e.value)
871+
== f"""2 validation errors for Test
872+
a
873+
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not_an_int', input_type=str]
874+
For further information visit https://errors.pydantic.dev/{pydantic_version}/v/int_parsing
875+
b
876+
The default factory uses validated data, but at least one validation error occurred [type=default_factory_not_called]
877+
For further information visit https://errors.pydantic.dev/{pydantic_version}/v/default_factory_not_called"""
878+
)

0 commit comments

Comments
 (0)