Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/marshmallow/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 19 additions & 1 deletion src/marshmallow/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.

Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand All @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
43 changes: 43 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Schema,
class_registry,
fields,
post_load,
validate,
validates,
validates_schema,
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem useful to me.
The assert isinstance is enough to demonstrate we went there only on valid input.


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):
Expand Down