Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@validates accepts multiple field names #1965

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
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
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
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)
sloria marked this conversation as resolved.
Show resolved Hide 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
Loading