Skip to content

Commit d71f499

Browse files
committed
Add skip_on_field_errors to field validators
1 parent 7bb19f0 commit d71f499

4 files changed

Lines changed: 93 additions & 3 deletions

File tree

src/marshmallow/decorators.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,24 @@ class MarshmallowHook:
8383
__marshmallow_hook__: dict[str, list[tuple[bool, typing.Any]]] | None = None
8484

8585

86-
def validates(*field_names: str) -> typing.Callable[..., typing.Any]:
86+
def validates(
87+
*field_names: str, skip_on_field_errors: bool = False
88+
) -> typing.Callable[..., typing.Any]:
8789
"""Register a validator method for field(s).
8890
8991
:param field_names: Names of the fields that the method validates.
92+
:param skip_on_field_errors: If ``True``, this validation method will be
93+
skipped whenever validation errors have been detected for the field.
9094
9195
.. versionchanged:: 4.0.0 Accepts multiple field names as positional arguments.
9296
.. versionchanged:: 4.0.0 Decorated methods receive ``data_key`` as a keyword argument.
9397
"""
94-
return set_hook(None, VALIDATES, field_names=field_names)
98+
return set_hook(
99+
None,
100+
VALIDATES,
101+
field_names=field_names,
102+
skip_on_field_errors=skip_on_field_errors,
103+
)
95104

96105

97106
def validates_schema(

src/marshmallow/schema.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,12 @@ def _call_and_store(getter_func, data, *, field_name, error_store, index=None):
527527
return error.valid_data or missing
528528
return value
529529

530+
@staticmethod
531+
def _has_field_error(errors: dict, field_name: str, index: int | None = None):
532+
if index is not None and isinstance(errors.get(index), dict):
533+
errors = errors[index]
534+
return field_name in errors
535+
530536
def _serialize(self, obj: typing.Any, *, many: bool = False):
531537
"""Serialize ``obj``.
532538
@@ -1140,6 +1146,9 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool)
11401146
field_obj.data_key if field_obj.data_key is not None else field_name
11411147
)
11421148
do_validate = functools.partial(validator, data_key=data_key)
1149+
skip_on_field_errors = validator_kwargs.get(
1150+
"skip_on_field_errors", False
1151+
)
11431152

11441153
if many:
11451154
for idx, item in enumerate(data):
@@ -1148,12 +1157,17 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool)
11481157
except KeyError:
11491158
pass
11501159
else:
1160+
index = idx if self.opts.index_errors else None
1161+
if skip_on_field_errors and self._has_field_error(
1162+
error_store.errors, data_key, index=index
1163+
):
1164+
continue
11511165
validated_value = self._call_and_store(
11521166
getter_func=do_validate,
11531167
data=value,
11541168
field_name=data_key,
11551169
error_store=error_store,
1156-
index=(idx if self.opts.index_errors else None),
1170+
index=index,
11571171
)
11581172
if validated_value is missing:
11591173
item.pop(field_name, None)
@@ -1163,6 +1177,10 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool)
11631177
except KeyError:
11641178
pass
11651179
else:
1180+
if skip_on_field_errors and self._has_field_error(
1181+
error_store.errors, data_key
1182+
):
1183+
continue
11661184
validated_value = self._call_and_store(
11671185
getter_func=do_validate,
11681186
data=value,

tests/test_decorators.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,26 @@ def validate_many(self, data, many, **kwargs):
693693
assert "bar" in errors[0]
694694
assert "_schema" not in errors
695695

696+
def test_field_validator_skip_on_field_errors(self):
697+
class MySchema(Schema):
698+
foo = fields.Int(required=True, validate=validate.Equal(3))
699+
bar = fields.Int(required=True)
700+
701+
@validates("foo", skip_on_field_errors=True)
702+
def validate_foo(self, value, **kwargs):
703+
raise AssertionError("should not validate fields with errors")
704+
705+
@validates("bar", skip_on_field_errors=True)
706+
def validate_bar(self, value, **kwargs):
707+
raise ValidationError("from validates")
708+
709+
errors = MySchema().validate({"foo": 2, "bar": 2})
710+
711+
assert errors == {
712+
"foo": ["Must be equal to 3."],
713+
"bar": ["from validates"],
714+
}
715+
696716
# https://github.com/marshmallow-code/marshmallow/issues/2170
697717
def test_data_key_is_used_in_errors_dict(self):
698718
class MySchema(Schema):

tests/test_schema.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
Schema,
1515
class_registry,
1616
fields,
17+
post_load,
1718
validate,
1819
validates,
1920
validates_schema,
@@ -1991,6 +1992,48 @@ def validates_inner(self, data, **kwargs):
19911992
assert "inner" in errors
19921993
assert "_schema" in errors["inner"]
19931994

1995+
# regression test for https://github.com/marshmallow-code/marshmallow/issues/2961
1996+
def test_nested_field_validator_can_skip_field_errors(self):
1997+
class ChildModel:
1998+
def __init__(self, uuid, name):
1999+
self.uuid = uuid
2000+
self.name = name
2001+
2002+
class Inner(Schema):
2003+
uuid = fields.UUID(required=True)
2004+
name = fields.String(required=True)
2005+
2006+
@post_load
2007+
def make_child(self, data, **kwargs):
2008+
return ChildModel(**data)
2009+
2010+
validated_values = []
2011+
2012+
class Outer(Schema):
2013+
inner = fields.Nested(Inner)
2014+
2015+
@validates("inner", skip_on_field_errors=True)
2016+
def validates_inner(self, value, **kwargs):
2017+
assert isinstance(value, ChildModel)
2018+
validated_values.append(value)
2019+
2020+
schema = Outer()
2021+
2022+
schema.load(
2023+
{
2024+
"inner": {
2025+
"uuid": "c81505b4-258b-4912-b8bc-8ac913d56736",
2026+
"name": "test",
2027+
}
2028+
}
2029+
)
2030+
2031+
with pytest.raises(ValidationError) as excinfo:
2032+
schema.load({"inner": {"uuid": "invalid-uuid", "name": "test"}})
2033+
2034+
assert excinfo.value.messages == {"inner": {"uuid": ["Not a valid UUID."]}}
2035+
assert len(validated_values) == 1
2036+
19942037
@pytest.mark.parametrize("unknown", (None, RAISE, INCLUDE, EXCLUDE))
19952038
def test_nested_unknown_validation(self, unknown):
19962039
class ChildSchema(Schema):

0 commit comments

Comments
 (0)