From e9e06dcda04455576c53d4c872b6369e4f800cdc Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Fri, 7 Jun 2024 11:45:50 +0200 Subject: [PATCH 1/5] Add list fail-fast config option --- python/pydantic_core/core_schema.py | 4 ++++ src/input/return_enums.rs | 4 ++++ src/validators/list.rs | 5 +++++ tests/validators/test_list.py | 17 +++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 6644c7fc8..504a0c626 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 = False, 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, diff --git a/src/input/return_enums.rs b/src/input/return_enums.rs index 22faaba71..0db3be6ba 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> { let mut output: Vec = Vec::with_capacity(capacity); let mut errors: Vec = 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), 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, max_length: Option, name: OnceLock, + 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/tests/validators/test_list.py b/tests/validators/test_list.py index 9f7d8738f..7a2225268 100644 --- a/tests/validators/test_list.py +++ b/tests/validators/test_list.py @@ -397,6 +397,23 @@ def f(v: int) -> int: ) +def test_list_fail_fast(): + s = core_schema.list_schema(core_schema.int_schema(), fail_fast=True) + 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) == [ + { + 'type': 'int_parsing', + 'loc': (1,), + 'msg': 'Input should be a valid integer, unable to parse string as an integer', + 'input': 'not-num', + } + ] + + class MySequence(collections.abc.Sequence): def __init__(self, data: List[Any]): self._data = data From d3e83ab323d5eed56571ec7786905a09b4177509 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Fri, 7 Jun 2024 12:20:12 +0200 Subject: [PATCH 2/5] Change fail_fast default value --- python/pydantic_core/core_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 504a0c626..73e6991da 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -1411,7 +1411,7 @@ def list_schema( *, min_length: int | None = None, max_length: int | None = None, - fail_fast: bool = False, + fail_fast: bool | None = None, strict: bool | None = None, ref: str | None = None, metadata: Any = None, From d4a126e361db575816027a84d25c8e9f1df7cf43 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Thu, 13 Jun 2024 22:38:56 +0200 Subject: [PATCH 3/5] Update test_list_fail_fast --- tests/validators/test_list.py | 48 +++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/tests/validators/test_list.py b/tests/validators/test_list.py index 7a2225268..b8bcee8ec 100644 --- a/tests/validators/test_list.py +++ b/tests/validators/test_list.py @@ -397,21 +397,49 @@ def f(v: int) -> int: ) -def test_list_fail_fast(): - s = core_schema.list_schema(core_schema.int_schema(), fail_fast=True) +@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) == [ - { - 'type': 'int_parsing', - 'loc': (1,), - 'msg': 'Input should be a valid integer, unable to parse string as an integer', - 'input': 'not-num', - } - ] + assert exc_info.value.errors(include_url=False) == expected class MySequence(collections.abc.Sequence): From 0c85ada5a514c6a333391640e17c5268288fc267 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Thu, 13 Jun 2024 23:15:16 +0200 Subject: [PATCH 4/5] Add fail_fast to set,frozen_set and tuple types --- src/input/return_enums.rs | 4 ++++ src/validators/frozenset.rs | 4 ++++ src/validators/set.rs | 5 +++++ src/validators/tuple.rs | 21 +++++++++++++++++++++ tests/validators/test_frozenset.py | 3 ++- 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/input/return_enums.rs b/src/input/return_enums.rs index 0db3be6ba..bbd1b6404 100644 --- a/src/input/return_enums.rs +++ b/src/input/return_enums.rs @@ -194,6 +194,7 @@ pub(crate) fn validate_iter_to_set<'py>( max_length: Option, validator: &CombinedValidator, state: &mut ValidationState<'_, 'py>, + fail_fast: bool, ) -> ValResult<()> { let mut errors: Vec = Vec::new(); for (index, item_result) in iter.enumerate() { @@ -224,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, max_length: Option, 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, item_validator: &'a CombinedValidator, state: &'a mut ValidationState<'s, 'py>, + fail_fast: bool, } impl<'py, T, I> ConsumeIterator> 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/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, max_length: Option, 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, item_validator: &'a CombinedValidator, state: &'a mut ValidationState<'s, 'py>, + fail_fast: bool, } impl<'py, T, I> ConsumeIterator> 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..57711c4f8 100644 --- a/src/validators/tuple.rs +++ b/src/validators/tuple.rs @@ -19,6 +19,7 @@ pub struct TupleValidator { min_length: Option, max_length: Option, 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>, actual_length: Option, + 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,6 +217,7 @@ impl TupleValidator { &self.validators, collection_iter, actual_length, + self.fail_fast, )?; // Generate an error if there are any extra items: diff --git a/tests/validators/test_frozenset.py b/tests/validators/test_frozenset.py index 407e0a579..6252e1fcc 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)' From 27db075c86229719575952dc2597d979f04548f9 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sat, 15 Jun 2024 12:51:08 +0200 Subject: [PATCH 5/5] Add tests to cover fail-fast feature --- python/pydantic_core/core_schema.py | 12 ++++++ src/validators/tuple.rs | 4 ++ tests/validators/test_frozenset.py | 44 ++++++++++++++++++++++ tests/validators/test_set.py | 44 ++++++++++++++++++++++ tests/validators/test_tuple.py | 58 ++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 2 deletions(-) diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index 73e6991da..14912f48c 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -1551,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 @@ -1563,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, @@ -1587,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 @@ -1598,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, @@ -1610,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 @@ -1621,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, @@ -1643,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 @@ -1653,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, @@ -1665,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 @@ -1676,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, @@ -1698,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 @@ -1708,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/validators/tuple.rs b/src/validators/tuple.rs index 57711c4f8..8acbf2beb 100644 --- a/src/validators/tuple.rs +++ b/src/validators/tuple.rs @@ -220,6 +220,10 @@ impl TupleValidator { 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 6252e1fcc..e8ddbfc3b 100644 --- a/tests/validators/test_frozenset.py +++ b/tests/validators/test_frozenset.py @@ -297,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_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