Skip to content

Commit

Permalink
aggregate root validation errors (#1586)
Browse files Browse the repository at this point in the history
* aggregate root validation errors

* add changelog

* update language in docs around root validators and prior failure

* factor out unique_list
  • Loading branch information
beezee committed Jul 3, 2020
1 parent dc72ae0 commit e3c5e1d
Show file tree
Hide file tree
Showing 6 changed files with 60 additions and 9 deletions.
1 change: 1 addition & 0 deletions 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.
2 changes: 1 addition & 1 deletion docs/usage/validators.md
Expand Up @@ -109,7 +109,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.
Expand Down
7 changes: 4 additions & 3 deletions pydantic/main.py
Expand Up @@ -42,6 +42,7 @@
generate_model_signature,
lenient_issubclass,
sequence_like,
unique_list,
validate_field_name,
)

Expand Down Expand Up @@ -291,13 +292,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,
Expand Down Expand Up @@ -924,7 +926,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_)
Expand Down
17 changes: 17 additions & 0 deletions pydantic/utils.py
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/test_utils.py
Expand Up @@ -20,6 +20,7 @@
import_string,
lenient_issubclass,
truncate,
unique_list,
)
from pydantic.version import version_info

Expand Down Expand Up @@ -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})
Expand Down
28 changes: 23 additions & 5 deletions tests/test_validators.py
Expand Up @@ -756,6 +756,7 @@ def test_root_validator():
class Model(BaseModel):
a: int = 1
b: str
c: str

@validator('b')
def repeat_b(cls, v):
Expand All @@ -768,19 +769,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():
Expand Down

0 comments on commit e3c5e1d

Please sign in to comment.