diff --git a/changes/1586-beezee.md b/changes/1586-beezee.md new file mode 100644 index 0000000000..f5259ee0d6 --- /dev/null +++ b/changes/1586-beezee.md @@ -0,0 +1 @@ +Adjust handling of root validators so that errors are aggregated from _all_ failing root validators, instead of reporting on only the first root validator to fail. diff --git a/docs/usage/validators.md b/docs/usage/validators.md index 26b588e4ec..e9e069316c 100644 --- a/docs/usage/validators.md +++ b/docs/usage/validators.md @@ -99,7 +99,7 @@ validation occurs (and are provided with the raw input data), or `pre=False` (th they're called after field validation. Field validation will not occur if `pre=True` root validators raise an error. As with field validators, -"post" (i.e. `pre=False`) root validators by default will be called even if field validation fails; this +"post" (i.e. `pre=False`) root validators by default will be called even if prior validators fail; this behaviour can be changed by setting the `skip_on_failure=True` keyword argument to the validator. The `values` argument will be a dict containing the values which passed field validation and field defaults where applicable. diff --git a/pydantic/main.py b/pydantic/main.py index bb01d2e3cc..10a6768d3b 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -42,6 +42,7 @@ generate_model_signature, lenient_issubclass, sequence_like, + unique_list, validate_field_name, ) @@ -287,13 +288,14 @@ def __new__(mcs, name, bases, namespace, **kwargs): # noqa C901 else: json_encoder = pydantic_encoder pre_rv_new, post_rv_new = extract_root_validators(namespace) + new_namespace = { '__config__': config, '__fields__': fields, '__field_defaults__': fields_defaults, '__validators__': vg.validators, - '__pre_root_validators__': pre_root_validators + pre_rv_new, - '__post_root_validators__': post_root_validators + post_rv_new, + '__pre_root_validators__': unique_list(pre_root_validators + pre_rv_new), + '__post_root_validators__': unique_list(post_root_validators + post_rv_new), '__schema_cache__': {}, '__json_encoder__': staticmethod(json_encoder), '__custom_root_type__': _custom_root_type, @@ -915,7 +917,6 @@ def validate_model( # noqa: C901 (ignore complexity) values = validator(cls_, values) except (ValueError, TypeError, AssertionError) as exc: errors.append(ErrorWrapper(exc, loc=ROOT_KEY)) - break if errors: return values, fields_set, ValidationError(errors, cls_) diff --git a/pydantic/utils.py b/pydantic/utils.py index fdd4f59a07..8421d186c6 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -221,6 +221,23 @@ def to_camel(string: str) -> str: return ''.join(word.capitalize() for word in string.split('_')) +T = TypeVar('T') + + +def unique_list(input_list: Union[List[T], Tuple[T, ...]]) -> List[T]: + """ + Make a list unique while maintaining order. + """ + result = [] + unique_set = set() + for v in input_list: + if v not in unique_set: + unique_set.add(v) + result.append(v) + + return result + + class PyObjectStr(str): """ String class where repr doesn't include quotes. Useful with Representation when you want to return a string diff --git a/tests/test_utils.py b/tests/test_utils.py index 5dba3ebc48..d59f2e3ed9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -20,6 +20,7 @@ import_string, lenient_issubclass, truncate, + unique_list, ) from pydantic.version import version_info @@ -101,6 +102,19 @@ def test_truncate(input_value, output): assert truncate(input_value, max_len=20) == output +@pytest.mark.parametrize( + 'input_value,output', + [ + ([], []), + ([1, 1, 1, 2, 1, 2, 3, 2, 3, 1, 4, 2, 3, 1], [1, 2, 3, 4]), + (['a', 'a', 'b', 'a', 'b', 'c', 'b', 'c', 'a'], ['a', 'b', 'c']), + ], +) +def test_unique_list(input_value, output): + assert unique_list(input_value) == output + assert unique_list(unique_list(input_value)) == unique_list(input_value) + + def test_value_items(): v = ['a', 'b', 'c'] vi = ValueItems(v, {0, -1}) diff --git a/tests/test_validators.py b/tests/test_validators.py index 2ff9359765..4a977bec09 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -737,6 +737,7 @@ def test_root_validator(): class Model(BaseModel): a: int = 1 b: str + c: str @validator('b') def repeat_b(cls, v): @@ -749,19 +750,36 @@ def example_root_validator(cls, values): raise ValueError('foobar') return dict(values, b='changed') - assert Model(a='123', b='bar').dict() == {'a': 123, 'b': 'changed'} + @root_validator + def example_root_validator2(cls, values): + root_val_values.append(values) + if 'snap' in values.get('c', ''): + raise ValueError('foobar2') + return dict(values, c='changed') + + assert Model(a='123', b='bar', c='baz').dict() == {'a': 123, 'b': 'changed', 'c': 'changed'} with pytest.raises(ValidationError) as exc_info: - Model(b='snap dragon') - assert exc_info.value.errors() == [{'loc': ('__root__',), 'msg': 'foobar', 'type': 'value_error'}] + Model(b='snap dragon', c='snap dragon2') + assert exc_info.value.errors() == [ + {'loc': ('__root__',), 'msg': 'foobar', 'type': 'value_error'}, + {'loc': ('__root__',), 'msg': 'foobar2', 'type': 'value_error'}, + ] with pytest.raises(ValidationError) as exc_info: - Model(a='broken', b='bar') + Model(a='broken', b='bar', c='baz') assert exc_info.value.errors() == [ {'loc': ('a',), 'msg': 'value is not a valid integer', 'type': 'type_error.integer'} ] - assert root_val_values == [{'a': 123, 'b': 'barbar'}, {'a': 1, 'b': 'snap dragonsnap dragon'}, {'b': 'barbar'}] + assert root_val_values == [ + {'a': 123, 'b': 'barbar', 'c': 'baz'}, + {'a': 123, 'b': 'changed', 'c': 'baz'}, + {'a': 1, 'b': 'snap dragonsnap dragon', 'c': 'snap dragon2'}, + {'a': 1, 'b': 'snap dragonsnap dragon', 'c': 'snap dragon2'}, + {'b': 'barbar', 'c': 'baz'}, + {'b': 'changed', 'c': 'baz'}, + ] def test_root_validator_pre():