diff --git a/src/marshmallow/decorators.py b/src/marshmallow/decorators.py index 7b4af4b56..a1d42f2b8 100644 --- a/src/marshmallow/decorators.py +++ b/src/marshmallow/decorators.py @@ -83,15 +83,24 @@ class MarshmallowHook: __marshmallow_hook__: dict[str, list[tuple[bool, typing.Any]]] | None = None -def validates(*field_names: str) -> typing.Callable[..., typing.Any]: +def validates( + *field_names: str, skip_on_field_errors: bool = False +) -> typing.Callable[..., typing.Any]: """Register a validator method for field(s). :param field_names: Names of the fields that the method validates. + :param skip_on_field_errors: If ``True``, this validation method will be + skipped whenever validation errors have been detected for the field. .. versionchanged:: 4.0.0 Accepts multiple field names as positional arguments. .. versionchanged:: 4.0.0 Decorated methods receive ``data_key`` as a keyword argument. """ - return set_hook(None, VALIDATES, field_names=field_names) + return set_hook( + None, + VALIDATES, + field_names=field_names, + skip_on_field_errors=skip_on_field_errors, + ) def validates_schema( diff --git a/src/marshmallow/schema.py b/src/marshmallow/schema.py index 24efa9028..d9bd7590c 100644 --- a/src/marshmallow/schema.py +++ b/src/marshmallow/schema.py @@ -527,6 +527,12 @@ def _call_and_store(getter_func, data, *, field_name, error_store, index=None): return error.valid_data or missing return value + @staticmethod + def _has_field_error(errors: dict, field_name: str, index: int | None = None): + if index is not None and isinstance(errors.get(index), dict): + errors = errors[index] + return field_name in errors + def _serialize(self, obj: typing.Any, *, many: bool = False): """Serialize ``obj``. @@ -1140,6 +1146,9 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) field_obj.data_key if field_obj.data_key is not None else field_name ) do_validate = functools.partial(validator, data_key=data_key) + skip_on_field_errors = validator_kwargs.get( + "skip_on_field_errors", False + ) if many: for idx, item in enumerate(data): @@ -1148,12 +1157,17 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) except KeyError: pass else: + index = idx if self.opts.index_errors else None + if skip_on_field_errors and self._has_field_error( + error_store.errors, data_key, index=index + ): + continue validated_value = self._call_and_store( getter_func=do_validate, data=value, field_name=data_key, error_store=error_store, - index=(idx if self.opts.index_errors else None), + index=index, ) if validated_value is missing: item.pop(field_name, None) @@ -1163,6 +1177,10 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) except KeyError: pass else: + if skip_on_field_errors and self._has_field_error( + error_store.errors, data_key + ): + continue validated_value = self._call_and_store( getter_func=do_validate, data=value, diff --git a/tests/test_decorators.py b/tests/test_decorators.py index fce7e442b..075f3045b 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -693,6 +693,26 @@ def validate_many(self, data, many, **kwargs): assert "bar" in errors[0] assert "_schema" not in errors + def test_field_validator_skip_on_field_errors(self): + class MySchema(Schema): + foo = fields.Int(required=True, validate=validate.Equal(3)) + bar = fields.Int(required=True) + + @validates("foo", skip_on_field_errors=True) + def validate_foo(self, value, **kwargs): + raise AssertionError("should not validate fields with errors") + + @validates("bar", skip_on_field_errors=True) + def validate_bar(self, value, **kwargs): + raise ValidationError("from validates") + + errors = MySchema().validate({"foo": 2, "bar": 2}) + + assert errors == { + "foo": ["Must be equal to 3."], + "bar": ["from validates"], + } + # https://github.com/marshmallow-code/marshmallow/issues/2170 def test_data_key_is_used_in_errors_dict(self): class MySchema(Schema): diff --git a/tests/test_schema.py b/tests/test_schema.py index ff2811737..89fea0374 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -14,6 +14,7 @@ Schema, class_registry, fields, + post_load, validate, validates, validates_schema, @@ -1991,6 +1992,48 @@ def validates_inner(self, data, **kwargs): assert "inner" in errors assert "_schema" in errors["inner"] + # regression test for https://github.com/marshmallow-code/marshmallow/issues/2961 + def test_nested_field_validator_can_skip_field_errors(self): + class ChildModel: + def __init__(self, uuid, name): + self.uuid = uuid + self.name = name + + class Inner(Schema): + uuid = fields.UUID(required=True) + name = fields.String(required=True) + + @post_load + def make_child(self, data, **kwargs): + return ChildModel(**data) + + validated_values = [] + + class Outer(Schema): + inner = fields.Nested(Inner) + + @validates("inner", skip_on_field_errors=True) + def validates_inner(self, value, **kwargs): + assert isinstance(value, ChildModel) + validated_values.append(value) + + schema = Outer() + + schema.load( + { + "inner": { + "uuid": "c81505b4-258b-4912-b8bc-8ac913d56736", + "name": "test", + } + } + ) + + with pytest.raises(ValidationError) as excinfo: + schema.load({"inner": {"uuid": "invalid-uuid", "name": "test"}}) + + assert excinfo.value.messages == {"inner": {"uuid": ["Not a valid UUID."]}} + assert len(validated_values) == 1 + @pytest.mark.parametrize("unknown", (None, RAISE, INCLUDE, EXCLUDE)) def test_nested_unknown_validation(self, unknown): class ChildSchema(Schema):