Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,4 @@ Contributors (chronological)
- Peter C `@somethingnew2-0 <https://github.com/somethingnew2-0>`_
- Marcel Jackwerth `@mrcljx` <https://github.com/mrcljx>`_
- Fares Abubaker `@Fares-Abubaker <https://github.com/Fares-Abubaker>`_
- Dharanikumar Sekar `@dharani7998 <https://github.com/dharani7998>`_
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ Features:
`TimeDelta <marshmallow.fields.TimeDelta>`, and `Enum <marshmallow.fields.Enum>`
accept their internal value types as valid input (:issue:`1415`).
Thanks :user:`bitdancer` for the suggestion.
- `@validates <marshmallow.validates>` accepts multiple field names (:issue:`1960`).
*Backwards-incompatible*: Decorated methods now receive ``data_key`` as a keyword argument.
Thanks :user:`dpriskorn` for the suggestion and :user:`dharani7998` for the PR.

Other changes:

Expand Down
22 changes: 20 additions & 2 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ You may also pass a collection (list, tuple, generator) of callables to ``valida
Field validators as methods
+++++++++++++++++++++++++++

It is sometimes convenient to write validators as methods. Use the `validates <marshmallow.decorators.validates>` decorator to register field validator methods.
It is sometimes convenient to write validators as methods. Use the `validates <marshmallow.validates>` decorator to register field validator methods.

.. code-block:: python

Expand All @@ -301,12 +301,30 @@ It is sometimes convenient to write validators as methods. Use the `validates <m
quantity = fields.Integer()

@validates("quantity")
def validate_quantity(self, value):
def validate_quantity(self, value: int, data_key: str) -> None:
if value < 0:
raise ValidationError("Quantity must be greater than 0.")
if value > 30:
raise ValidationError("Quantity must not be greater than 30.")

.. note::

You can pass multiple field names to the `validates <marshmallow.validates>` decorator.

.. code-block:: python

from marshmallow import Schema, fields, validates, ValidationError


class UserSchema(Schema):
name = fields.Str(required=True)
nickname = fields.Str(required=True)

@validates("name", "nickname")
def validate_names(self, value: str, data_key: str) -> None:
if len(value) < 3:
raise ValidationError("Too short")

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 would be the right place to show the use of data_key like in the upgrading guide.



Required fields
---------------
Expand Down
38 changes: 38 additions & 0 deletions docs/upgrading.rst
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,44 @@ To automatically generate schema fields from model classes, consider using a sep
name = auto_field()
birthdate = auto_field()

`@validates <marshmallow.validates>` accepts multiple field names
*****************************************************************

The `@validates <marshmallow.validates>` decorator now accepts multiple field names as arguments.
Decorated methods receive ``data_key`` as a keyword argument.

.. code-block:: python

from marshmallow import fields, Schema, validates


# 3.x
class UserSchema(Schema):
name = fields.Str(required=True)
nickname = fields.Str(required=True)

@validates("name")
def validate_name(self, value: str) -> None:
if len(value) < 3:
raise ValidationError('"name" too short')

@validates("nickname")
def validate_nickname(self, value: str) -> None:
if len(value) < 3:
raise ValidationError('"nickname" too short')


# 4.x
class UserSchema(Schema):
name = fields.Str(required=True)
nickname = fields.Str(required=True)

@validates("name", "nickname")
def validate_names(self, value: str, data_key: str) -> None:
if len(value) < 3:
raise ValidationError(f'"{data_key}" too short')


Remove ``ordered`` from the `SchemaOpts <marshmallow.SchemaOpts>` constructor
*****************************************************************************

Expand Down
11 changes: 7 additions & 4 deletions src/marshmallow/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,15 @@ class MarshmallowHook:
__marshmallow_hook__: dict[str, list[tuple[bool, Any]]] | None = None


def validates(field_name: str) -> Callable[..., Any]:
"""Register a field validator.
def validates(*field_names: str) -> Callable[..., Any]:
"""Register a validator method for field(s).

:param field_name: Name of the field that the method validates.
:param field_names: Names of the fields that the method validates.

.. 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_name=field_name)
return set_hook(None, VALIDATES, field_names=field_names)
Comment thread
sloria marked this conversation as resolved.


def validates_schema(
Expand Down
64 changes: 34 additions & 30 deletions src/marshmallow/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1104,48 +1104,52 @@ def _invoke_load_processors(
def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool):
for attr_name, _, validator_kwargs in self._hooks[VALIDATES]:
validator = getattr(self, attr_name)
field_name = validator_kwargs["field_name"]

try:
field_obj = self.fields[field_name]
except KeyError as error:
if field_name in self.declared_fields:
continue
raise ValueError(f'"{field_name}" field does not exist.') from error
field_names = validator_kwargs["field_names"]

data_key = (
field_obj.data_key if field_obj.data_key is not None else field_name
)
if many:
for idx, item in enumerate(data):
for field_name in field_names:
try:
field_obj = self.fields[field_name]
except KeyError as error:
if field_name in self.declared_fields:
continue
raise ValueError(f'"{field_name}" field does not exist.') from error

data_key = (
field_obj.data_key if field_obj.data_key is not None else field_name
)
do_validate = functools.partial(validator, data_key=data_key)

if many:
for idx, item in enumerate(data):
try:
value = item[field_obj.attribute or field_name]
except KeyError:
pass
else:
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),
)
if validated_value is missing:
item.pop(field_name, None)
else:
try:
value = item[field_obj.attribute or field_name]
value = data[field_obj.attribute or field_name]
except KeyError:
pass
else:
validated_value = self._call_and_store(
getter_func=validator,
getter_func=do_validate,
data=value,
field_name=data_key,
error_store=error_store,
index=(idx if self.opts.index_errors else None),
)
if validated_value is missing:
item.pop(field_name, None)
else:
try:
value = data[field_obj.attribute or field_name]
except KeyError:
pass
else:
validated_value = self._call_and_store(
getter_func=validator,
data=value,
field_name=data_key,
error_store=error_store,
)
if validated_value is missing:
data.pop(field_name, None)
data.pop(field_name, None)

def _invoke_schema_validators(
self,
Expand Down
4 changes: 2 additions & 2 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ class InnerSchema(Schema):
foo = fields.Raw()

@validates("foo")
def validate_foo(self, value):
def validate_foo(self, value, **kwargs):
if "foo_context" not in Context[dict].get():
raise ValidationError("Missing context")

Expand All @@ -132,7 +132,7 @@ class InnerSchema(Schema):
foo = fields.Raw()

@validates("foo")
def validate_foo(self, value):
def validate_foo(self, value, **kwargs):
if "foo_context" not in Context[dict].get():
raise ValidationError("Missing context")

Expand Down
29 changes: 23 additions & 6 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ class ValidatesSchema(Schema):
foo = fields.Int()

@validates("foo")
def validate_foo(self, value):
def validate_foo(self, value, **kwargs):
if value != 42:
raise ValidationError("The answer to life the universe and everything.")

Expand All @@ -262,7 +262,7 @@ class VSchema(Schema):
s = fields.String()

@validates("s")
def validate_string(self, data):
def validate_string(self, data, **kwargs):
raise ValidationError("nope")

with pytest.raises(ValidationError) as excinfo:
Expand All @@ -276,7 +276,7 @@ class S1(Schema):
s = fields.String(attribute="string_name")

@validates("s")
def validate_string(self, data):
def validate_string(self, data, **kwargs):
raise ValidationError("nope")

with pytest.raises(ValidationError) as excinfo:
Expand Down Expand Up @@ -330,7 +330,7 @@ def test_validates_decorator(self):
def test_field_not_present(self):
class BadSchema(ValidatesSchema):
@validates("bar")
def validate_bar(self, value):
def validate_bar(self, value, **kwargs):
raise ValidationError("Never raised.")

schema = BadSchema()
Expand All @@ -344,7 +344,7 @@ class Schema2(ValidatesSchema):
bar = fields.Int(validate=validate.Equal(1))

@validates("bar")
def validate_bar(self, value):
def validate_bar(self, value, **kwargs):
if value != 2:
raise ValidationError("Must be 2")

Expand All @@ -371,7 +371,7 @@ class BadSchema(Schema):
foo = fields.String(data_key="foo-name")

@validates("foo")
def validate_string(self, data):
def validate_string(self, data, **kwargs):
raise ValidationError("nope")

schema = BadSchema()
Expand All @@ -385,6 +385,23 @@ def validate_string(self, data):
)
assert errors == {0: {"foo-name": ["nope"]}, 1: {"foo-name": ["nope"]}}

def test_validates_accepts_multiple_fields(self):
class BadSchema(Schema):
foo = fields.String()
bar = fields.String(data_key="Bar")

@validates("foo", "bar")
def validate_string(self, data: str, data_key: str):
raise ValidationError(f"'{data}' is invalid for {data_key}.")

schema = BadSchema()
with pytest.raises(ValidationError) as excinfo:
schema.load({"foo": "data", "Bar": "data2"})
assert excinfo.value.messages == {
"foo": ["'data' is invalid for foo."],
"Bar": ["'data2' is invalid for Bar."],
}


class TestValidatesSchemaDecorator:
def test_validator_nested_many_invalid_data(self):
Expand Down
6 changes: 3 additions & 3 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1741,11 +1741,11 @@ class MySchema(Schema):
b = fields.Raw()

@validates("a")
def validate_a(self, val):
def validate_a(self, val, **kwargs):
raise ValidationError({"code": "invalid_a"})

@validates("b")
def validate_b(self, val):
def validate_b(self, val, **kwargs):
raise ValidationError({"code": "invalid_b"})

s = MySchema(only=("b",))
Expand Down Expand Up @@ -1935,7 +1935,7 @@ class Outer(Schema):
inner = fields.Nested(Inner, many=True)

@validates("inner")
def validates_inner(self, data):
def validates_inner(self, data, **kwargs):
raise ValidationError("not a chance")

outer = Outer()
Expand Down