diff --git a/AUTHORS.rst b/AUTHORS.rst index dc645c3c6..5f1c33f59 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -176,3 +176,4 @@ Contributors (chronological) - Peter C `@somethingnew2-0 `_ - Marcel Jackwerth `@mrcljx` `_ - Fares Abubaker `@Fares-Abubaker `_ +- Dharanikumar Sekar `@dharani7998 `_ diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8cd5d977e..43bb8bf90 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,9 @@ Features: `TimeDelta `, and `Enum ` accept their internal value types as valid input (:issue:`1415`). Thanks :user:`bitdancer` for the suggestion. +- `@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: diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 05081ce80..1a1cd0c5b 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -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 ` decorator to register field validator methods. +It is sometimes convenient to write validators as methods. Use the `validates ` decorator to register field validator methods. .. code-block:: python @@ -301,12 +301,30 @@ It is sometimes convenient to write validators as methods. Use the `validates 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 ` 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") + Required fields --------------- diff --git a/docs/upgrading.rst b/docs/upgrading.rst index 1868240f9..7107a4b45 100644 --- a/docs/upgrading.rst +++ b/docs/upgrading.rst @@ -137,6 +137,44 @@ To automatically generate schema fields from model classes, consider using a sep name = auto_field() birthdate = auto_field() +`@validates ` accepts multiple field names +***************************************************************** + +The `@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 ` constructor ***************************************************************************** diff --git a/src/marshmallow/decorators.py b/src/marshmallow/decorators.py index 4e378c36f..ddc4a4df0 100644 --- a/src/marshmallow/decorators.py +++ b/src/marshmallow/decorators.py @@ -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) def validates_schema( diff --git a/src/marshmallow/schema.py b/src/marshmallow/schema.py index b42b3a09d..27a13101f 100644 --- a/src/marshmallow/schema.py +++ b/src/marshmallow/schema.py @@ -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, diff --git a/tests/test_context.py b/tests/test_context.py index 82d8339e9..8758b7eeb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -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") @@ -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") diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 3f3dd69ee..4a98e036f 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -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.") @@ -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: @@ -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: @@ -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() @@ -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") @@ -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() @@ -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): diff --git a/tests/test_schema.py b/tests/test_schema.py index 8606654c9..63c591c9c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -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",)) @@ -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()