diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 6644c7fc8..14912f48c 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -1399,6 +1399,7 @@ class ListSchema(TypedDict, total=False): items_schema: CoreSchema min_length: int max_length: int + fail_fast: bool strict: bool ref: str metadata: Any @@ -1410,6 +1411,7 @@ def list_schema( *, min_length: int | None = None, max_length: int | None = None, + fail_fast: bool | None = None, strict: bool | None = None, ref: str | None = None, metadata: Any = None, @@ -1430,6 +1432,7 @@ def list_schema( items_schema: The value must be a list of items that match this schema min_length: The value must be a list with at least this many items max_length: The value must be a list with at most this many items + fail_fast: Stop validation on the first error strict: The value must be a list with exactly this many items ref: optional unique identifier of the schema, used to reference the schema in other places metadata: Any other information you want to include with the schema, not used by pydantic-core @@ -1440,6 +1443,7 @@ def list_schema( items_schema=items_schema, min_length=min_length, max_length=max_length, + fail_fast=fail_fast, strict=strict, ref=ref, metadata=metadata, @@ -1547,6 +1551,7 @@ class TupleSchema(TypedDict, total=False): variadic_item_index: int min_length: int max_length: int + fail_fast: bool strict: bool ref: str metadata: Any @@ -1559,6 +1564,7 @@ def tuple_schema( variadic_item_index: int | None = None, min_length: int | None = None, max_length: int | None = None, + fail_fast: bool | None = None, strict: bool | None = None, ref: str | None = None, metadata: Any = None, @@ -1583,6 +1589,7 @@ def tuple_schema( variadic_item_index: The index of the schema in `items_schema` to be treated as variadic (following PEP 646) min_length: The value must be a tuple with at least this many items max_length: The value must be a tuple with at most this many items + fail_fast: Stop validation on the first error strict: The value must be a tuple with exactly this many items ref: Optional unique identifier of the schema, used to reference the schema in other places metadata: Any other information you want to include with the schema, not used by pydantic-core @@ -1594,6 +1601,7 @@ def tuple_schema( variadic_item_index=variadic_item_index, min_length=min_length, max_length=max_length, + fail_fast=fail_fast, strict=strict, ref=ref, metadata=metadata, @@ -1606,6 +1614,7 @@ class SetSchema(TypedDict, total=False): items_schema: CoreSchema min_length: int max_length: int + fail_fast: bool strict: bool ref: str metadata: Any @@ -1617,6 +1626,7 @@ def set_schema( *, min_length: int | None = None, max_length: int | None = None, + fail_fast: bool | None = None, strict: bool | None = None, ref: str | None = None, metadata: Any = None, @@ -1639,6 +1649,7 @@ def set_schema( items_schema: The value must be a set with items that match this schema min_length: The value must be a set with at least this many items max_length: The value must be a set with at most this many items + fail_fast: Stop validation on the first error strict: The value must be a set with exactly this many items ref: optional unique identifier of the schema, used to reference the schema in other places metadata: Any other information you want to include with the schema, not used by pydantic-core @@ -1649,6 +1660,7 @@ def set_schema( items_schema=items_schema, min_length=min_length, max_length=max_length, + fail_fast=fail_fast, strict=strict, ref=ref, metadata=metadata, @@ -1661,6 +1673,7 @@ class FrozenSetSchema(TypedDict, total=False): items_schema: CoreSchema min_length: int max_length: int + fail_fast: bool strict: bool ref: str metadata: Any @@ -1672,6 +1685,7 @@ def frozenset_schema( *, min_length: int | None = None, max_length: int | None = None, + fail_fast: bool | None = None, strict: bool | None = None, ref: str | None = None, metadata: Any = None, @@ -1694,6 +1708,7 @@ def frozenset_schema( items_schema: The value must be a frozenset with items that match this schema min_length: The value must be a frozenset with at least this many items max_length: The value must be a frozenset with at most this many items + fail_fast: Stop validation on the first error strict: The value must be a frozenset with exactly this many items ref: optional unique identifier of the schema, used to reference the schema in other places metadata: Any other information you want to include with the schema, not used by pydantic-core @@ -1704,6 +1719,7 @@ def frozenset_schema( items_schema=items_schema, min_length=min_length, max_length=max_length, + fail_fast=fail_fast, strict=strict, ref=ref, metadata=metadata, diff --git a/src/input/return_enums.rs b/src/input/return_enums.rs index 22faaba71..bbd1b6404 100644 --- a/src/input/return_enums.rs +++ b/src/input/return_enums.rs @@ -124,6 +124,7 @@ pub(crate) fn validate_iter_to_vec<'py>( mut max_length_check: MaxLengthCheck<'_, impl Input<'py> + ?Sized>, validator: &CombinedValidator, state: &mut ValidationState<'_, 'py>, + fail_fast: bool, ) -> ValResult<Vec<PyObject>> { let mut output: Vec<PyObject> = Vec::with_capacity(capacity); let mut errors: Vec<ValLineError> = Vec::new(); @@ -137,6 +138,9 @@ pub(crate) fn validate_iter_to_vec<'py>( Err(ValError::LineErrors(line_errors)) => { max_length_check.incr()?; errors.extend(line_errors.into_iter().map(|err| err.with_outer_location(index))); + if fail_fast { + break; + } } Err(ValError::Omit) => (), Err(err) => return Err(err), @@ -190,6 +194,7 @@ pub(crate) fn validate_iter_to_set<'py>( max_length: Option<usize>, validator: &CombinedValidator, state: &mut ValidationState<'_, 'py>, + fail_fast: bool, ) -> ValResult<()> { let mut errors: Vec<ValLineError> = Vec::new(); for (index, item_result) in iter.enumerate() { @@ -220,6 +225,9 @@ pub(crate) fn validate_iter_to_set<'py>( Err(ValError::Omit) => (), Err(err) => return Err(err), } + if fail_fast && !errors.is_empty() { + break; + } } if errors.is_empty() { diff --git a/src/validators/frozenset.rs b/src/validators/frozenset.rs index fcb7ffe7c..60fe451a7 100644 --- a/src/validators/frozenset.rs +++ b/src/validators/frozenset.rs @@ -17,6 +17,7 @@ pub struct FrozenSetValidator { min_length: Option<usize>, max_length: Option<usize>, name: String, + fail_fast: bool, } impl BuildValidator for FrozenSetValidator { @@ -42,6 +43,7 @@ impl Validator for FrozenSetValidator { max_length: self.max_length, item_validator: &self.item_validator, state, + fail_fast: self.fail_fast, })??; min_length_check!(input, "Frozenset", self.min_length, f_set); Ok(f_set.into_py(py)) @@ -59,6 +61,7 @@ struct ValidateToFrozenSet<'a, 's, 'py, I: Input<'py> + ?Sized> { max_length: Option<usize>, item_validator: &'a CombinedValidator, state: &'a mut ValidationState<'s, 'py>, + fail_fast: bool, } impl<'py, T, I> ConsumeIterator<PyResult<T>> for ValidateToFrozenSet<'_, '_, 'py, I> @@ -77,6 +80,7 @@ where self.max_length, self.item_validator, self.state, + self.fail_fast, ) } } diff --git a/src/validators/list.rs b/src/validators/list.rs index 21825ca54..87600fb81 100644 --- a/src/validators/list.rs +++ b/src/validators/list.rs @@ -18,6 +18,7 @@ pub struct ListValidator { min_length: Option<usize>, max_length: Option<usize>, name: OnceLock<String>, + fail_fast: bool, } pub fn get_items_schema( @@ -109,6 +110,7 @@ impl BuildValidator for ListValidator { min_length: schema.get_as(pyo3::intern!(py, "min_length"))?, max_length: schema.get_as(pyo3::intern!(py, "max_length"))?, name: OnceLock::new(), + fail_fast: schema.get_as(pyo3::intern!(py, "fail_fast"))?.unwrap_or(false), } .into()) } @@ -135,6 +137,7 @@ impl Validator for ListValidator { field_type: "List", item_validator: v, state, + fail_fast: self.fail_fast, })??, None => { if let Some(py_list) = seq.as_py_list() { @@ -184,6 +187,7 @@ struct ValidateToVec<'a, 's, 'py, I: Input<'py> + ?Sized> { field_type: &'static str, item_validator: &'a CombinedValidator, state: &'a mut ValidationState<'s, 'py>, + fail_fast: bool, } // pretty arbitrary default capacity when creating vecs from iteration @@ -204,6 +208,7 @@ where max_length_check, self.item_validator, self.state, + self.fail_fast, ) } } diff --git a/src/validators/set.rs b/src/validators/set.rs index 3ebd2e40b..281cfc37d 100644 --- a/src/validators/set.rs +++ b/src/validators/set.rs @@ -15,6 +15,7 @@ pub struct SetValidator { min_length: Option<usize>, max_length: Option<usize>, name: String, + fail_fast: bool, } macro_rules! set_build { @@ -42,6 +43,7 @@ macro_rules! set_build { min_length: schema.get_as(pyo3::intern!(py, "min_length"))?, max_length, name, + fail_fast: schema.get_as(pyo3::intern!(py, "fail_fast"))?.unwrap_or(false), } .into()) } @@ -72,6 +74,7 @@ impl Validator for SetValidator { max_length: self.max_length, item_validator: &self.item_validator, state, + fail_fast: self.fail_fast, })??; min_length_check!(input, "Set", self.min_length, set); Ok(set.into_py(py)) @@ -89,6 +92,7 @@ struct ValidateToSet<'a, 's, 'py, I: Input<'py> + ?Sized> { max_length: Option<usize>, item_validator: &'a CombinedValidator, state: &'a mut ValidationState<'s, 'py>, + fail_fast: bool, } impl<'py, T, I> ConsumeIterator<PyResult<T>> for ValidateToSet<'_, '_, 'py, I> @@ -107,6 +111,7 @@ where self.max_length, self.item_validator, self.state, + self.fail_fast, ) } } diff --git a/src/validators/tuple.rs b/src/validators/tuple.rs index 3c5adb186..8acbf2beb 100644 --- a/src/validators/tuple.rs +++ b/src/validators/tuple.rs @@ -19,6 +19,7 @@ pub struct TupleValidator { min_length: Option<usize>, max_length: Option<usize>, name: String, + fail_fast: bool, } impl BuildValidator for TupleValidator { @@ -50,6 +51,7 @@ impl BuildValidator for TupleValidator { min_length: schema.get_as(intern!(py, "min_length"))?, max_length: schema.get_as(intern!(py, "max_length"))?, name, + fail_fast: schema.get_as(intern!(py, "fail_fast"))?.unwrap_or(false), } .into()) } @@ -69,6 +71,7 @@ impl TupleValidator { item_validators: &[CombinedValidator], collection_iter: &mut NextCountingIterator<impl Iterator<Item = I>>, actual_length: Option<usize>, + fail_fast: bool, ) -> ValResult<()> { // Validate the head: for validator in item_validators { @@ -90,6 +93,9 @@ impl TupleValidator { } } } + if fail_fast && !errors.is_empty() { + return Ok(()); + } } Ok(()) @@ -128,8 +134,13 @@ impl TupleValidator { head_validators, collection_iter, actual_length, + self.fail_fast, )?; + if self.fail_fast && !errors.is_empty() { + return Ok(output); + } + let n_tail_validators = tail_validators.len(); if n_tail_validators == 0 { for (index, input_item) in collection_iter { @@ -141,6 +152,10 @@ impl TupleValidator { Err(ValError::Omit) => (), Err(err) => return Err(err), } + + if self.fail_fast && !errors.is_empty() { + return Ok(output); + } } } else { // Populate a buffer with the first n_tail_validators items @@ -172,6 +187,10 @@ impl TupleValidator { Err(ValError::Omit) => (), Err(err) => return Err(err), } + + if self.fail_fast && !errors.is_empty() { + return Ok(output); + } } // Validate the buffered items using the tail validators @@ -184,6 +203,7 @@ impl TupleValidator { tail_validators, &mut NextCountingIterator::new(tail_buffer.into_iter(), index), actual_length, + self.fail_fast, )?; } } else { @@ -197,8 +217,13 @@ impl TupleValidator { &self.validators, collection_iter, actual_length, + self.fail_fast, )?; + if self.fail_fast && !errors.is_empty() { + return Ok(output); + } + // Generate an error if there are any extra items: if collection_iter.next().is_some() { return Err(ValError::new( diff --git a/tests/validators/test_frozenset.py b/tests/validators/test_frozenset.py index 407e0a579..e8ddbfc3b 100644 --- a/tests/validators/test_frozenset.py +++ b/tests/validators/test_frozenset.py @@ -248,7 +248,8 @@ def test_repr(): 'title="frozenset[any]",' 'validator=FrozenSet(FrozenSetValidator{' 'strict:true,item_validator:Any(AnyValidator),min_length:Some(42),max_length:None,' - 'name:"frozenset[any]"' + 'name:"frozenset[any]",' + 'fail_fast:false' '}),' 'definitions=[],' 'cache_strings=True)' @@ -296,3 +297,47 @@ def test_frozenset_from_dict_items(input_value, items_schema, expected): output = v.validate_python(input_value) assert isinstance(output, frozenset) assert output == expected + + +@pytest.mark.parametrize( + 'fail_fast,expected', + [ + pytest.param( + True, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + }, + ], + id='fail_fast', + ), + pytest.param( + False, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + }, + { + 'type': 'int_parsing', + 'loc': (2,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'again', + }, + ], + id='not_fail_fast', + ), + ], +) +def test_frozenset_fail_fast(fail_fast, expected): + v = SchemaValidator({'type': 'frozenset', 'items_schema': {'type': 'int'}, 'fail_fast': fail_fast}) + + with pytest.raises(ValidationError) as exc_info: + v.validate_python([1, 'not-num', 'again']) + + assert exc_info.value.errors(include_url=False) == expected diff --git a/tests/validators/test_list.py b/tests/validators/test_list.py index 9f7d8738f..b8bcee8ec 100644 --- a/tests/validators/test_list.py +++ b/tests/validators/test_list.py @@ -397,6 +397,51 @@ def f(v: int) -> int: ) +@pytest.mark.parametrize( + 'fail_fast,expected', + [ + pytest.param( + True, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + } + ], + id='fail_fast', + ), + pytest.param( + False, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + }, + { + 'type': 'int_parsing', + 'loc': (2,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'again', + }, + ], + id='not_fail_fast', + ), + ], +) +def test_list_fail_fast(fail_fast, expected): + s = core_schema.list_schema(core_schema.int_schema(), fail_fast=fail_fast) + v = SchemaValidator(s) + + with pytest.raises(ValidationError) as exc_info: + v.validate_python([1, 'not-num', 'again']) + + assert exc_info.value.errors(include_url=False) == expected + + class MySequence(collections.abc.Sequence): def __init__(self, data: List[Any]): self._data = data diff --git a/tests/validators/test_set.py b/tests/validators/test_set.py index e58cedff8..3c044658c 100644 --- a/tests/validators/test_set.py +++ b/tests/validators/test_set.py @@ -277,3 +277,47 @@ def test_set_any(input_value, expected): output = v.validate_python(input_value) assert output == expected assert isinstance(output, set) + + +@pytest.mark.parametrize( + 'fail_fast,expected', + [ + pytest.param( + True, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + }, + ], + id='fail_fast', + ), + pytest.param( + False, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + }, + { + 'type': 'int_parsing', + 'loc': (2,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'again', + }, + ], + id='not_fail_fast', + ), + ], +) +def test_set_fail_fast(fail_fast, expected): + v = SchemaValidator({'type': 'set', 'items_schema': {'type': 'int'}, 'fail_fast': fail_fast}) + + with pytest.raises(ValidationError) as exc_info: + v.validate_python([1, 'not-num', 'again']) + + assert exc_info.value.errors(include_url=False) == expected diff --git a/tests/validators/test_tuple.py b/tests/validators/test_tuple.py index a3d548ced..d25ef9547 100644 --- a/tests/validators/test_tuple.py +++ b/tests/validators/test_tuple.py @@ -58,8 +58,9 @@ def test_tuple_strict_passes_with_tuple(variadic_item_index, items, input_value, assert v.validate_python(input_value) == expected -def test_empty_positional_tuple(): - v = SchemaValidator({'type': 'tuple', 'items_schema': []}) +@pytest.mark.parametrize('fail_fast', [True, False]) +def test_empty_positional_tuple(fail_fast): + v = SchemaValidator({'type': 'tuple', 'items_schema': [], 'fail_fast': fail_fast}) assert v.validate_python(()) == () assert v.validate_python([]) == () with pytest.raises(ValidationError) as exc_info: @@ -493,3 +494,56 @@ def test_length_constraints_omit(input_value, expected): v.validate_python(input_value) else: assert v.validate_python(input_value) == expected + + +@pytest.mark.parametrize( + 'fail_fast,expected', + [ + pytest.param( + True, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + } + ], + id='fail_fast', + ), + pytest.param( + False, + [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + }, + { + 'type': 'float_parsing', + 'loc': (2,), + 'msg': 'Input should be a valid number, unable to parse string as a number', + 'input': 'again', + }, + ], + id='not_fail_fast', + ), + ], +) +def test_tuple_fail_fast(fail_fast, expected): + s = core_schema.tuple_schema( + [ + core_schema.str_schema(), + core_schema.int_schema(), + core_schema.float_schema(), + ], + variadic_item_index=None, + fail_fast=fail_fast, + ) + v = SchemaValidator(s) + + with pytest.raises(ValidationError) as exc_info: + v.validate_python(['str', 'not-num', 'again']) + + assert exc_info.value.errors(include_url=False) == expected