From a01fbaaecc37bb12191bff09da9e6979dea7834a Mon Sep 17 00:00:00 2001 From: dharani7998 Date: Thu, 24 Mar 2022 21:39:14 +0530 Subject: [PATCH 1/5] multiple fields in validates decorator --- src/marshmallow/decorators.py | 4 +-- src/marshmallow/schema.py | 59 ++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/marshmallow/decorators.py b/src/marshmallow/decorators.py index c71038d7a..906b031c8 100644 --- a/src/marshmallow/decorators.py +++ b/src/marshmallow/decorators.py @@ -77,12 +77,12 @@ class MarshmallowHook: ) # type: Optional[Dict[Union[Tuple[str, bool], str], Any]] -def validates(field_name: str) -> Callable[..., Any]: +def validates(*field_names: str) -> Callable[..., Any]: """Register a field validator. :param str field_name: Name of the field that the method validates. """ - 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 ff119a467..62c61bdda 100644 --- a/src/marshmallow/schema.py +++ b/src/marshmallow/schema.py @@ -1097,22 +1097,38 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) for attr_name in self._hooks[VALIDATES]: validator = getattr(self, attr_name) validator_kwargs = validator.__marshmallow_hook__[VALIDATES] - field_name = validator_kwargs["field_name"] + field_names = validator_kwargs["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 + 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 - ) - if many: - for idx, item in enumerate(data): + 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): + try: + value = item[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, + index=(idx if self.opts.index_errors else None), + ) + if validated_value is missing: + data[idx].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: @@ -1121,24 +1137,9 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) data=value, field_name=data_key, error_store=error_store, - index=(idx if self.opts.index_errors else None), ) if validated_value is missing: - data[idx].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, From eb0577d4b3fdcf64383c08c88219f71f859757dd Mon Sep 17 00:00:00 2001 From: dharani7998 Date: Thu, 24 Mar 2022 21:49:37 +0530 Subject: [PATCH 2/5] authors.rst --- AUTHORS.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index a877bc466..3bd5e9116 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -168,3 +168,4 @@ Contributors (chronological) - Ben Windsor `@bwindsor `_ - Kevin Kirsche `@kkirsche `_ - Isira Seneviratne `@Isira-Seneviratne `_ +- Dharanikumar Sekar `@dharani7998 `_ From 68c55c1c4c567069846aa078c707235a379b9cae Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Thu, 16 Jan 2025 17:00:10 -0500 Subject: [PATCH 3/5] Add test and update docs --- CHANGELOG.rst | 2 ++ docs/quickstart.rst | 20 +++++++++++++++++++- src/marshmallow/decorators.py | 4 ++-- tests/test_decorators.py | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 754bf8b37..f5921fffe 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,8 @@ 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`). + 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..9d9d7bae7 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 @@ -307,6 +307,24 @@ It is sometimes convenient to write validators as methods. Use the `validates 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) -> str: + if len(value) < 3: + raise ValidationError("Too short") + return value Required fields --------------- diff --git a/src/marshmallow/decorators.py b/src/marshmallow/decorators.py index 5c9ca2777..d2929e811 100644 --- a/src/marshmallow/decorators.py +++ b/src/marshmallow/decorators.py @@ -84,9 +84,9 @@ class MarshmallowHook: def validates(*field_names: str) -> Callable[..., Any]: - """Register a field validator. + """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. """ return set_hook(None, VALIDATES, field_names=field_names) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index d8a6f77d3..3c659cb9f 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -382,6 +382,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): + raise ValidationError(f"'{data}' is invalid.") + + schema = BadSchema() + with pytest.raises(ValidationError) as excinfo: + schema.load({"foo": "data", "Bar": "data2"}) + assert excinfo.value.messages == { + "foo": ["'data' is invalid."], + "Bar": ["'data2' is invalid."], + } + class TestValidatesSchemaDecorator: def test_validator_nested_many_invalid_data(self): From 0754960f46af81d84c0ae7eb760cf5117394feb3 Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Sat, 18 Jan 2025 16:35:07 -0500 Subject: [PATCH 4/5] Pass data_key --- src/marshmallow/decorators.py | 3 +++ src/marshmallow/schema.py | 8 ++++++-- tests/test_context.py | 4 ++-- tests/test_decorators.py | 20 ++++++++++---------- tests/test_schema.py | 6 +++--- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/marshmallow/decorators.py b/src/marshmallow/decorators.py index d2929e811..28a9e95cb 100644 --- a/src/marshmallow/decorators.py +++ b/src/marshmallow/decorators.py @@ -87,6 +87,9 @@ def validates(*field_names: str) -> Callable[..., Any]: """Register a validator method for field(s). :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 method receives ``data_key`` as a keyword argument. """ return set_hook(None, VALIDATES, field_names=field_names) diff --git a/src/marshmallow/schema.py b/src/marshmallow/schema.py index 9fae878b2..ce7b519c1 100644 --- a/src/marshmallow/schema.py +++ b/src/marshmallow/schema.py @@ -5,6 +5,7 @@ import copy import datetime as dt import decimal +import functools import inspect import json import typing @@ -1097,6 +1098,7 @@ 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_names = validator_kwargs["field_names"] for field_name in field_names: @@ -1110,6 +1112,8 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) 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: @@ -1118,7 +1122,7 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) 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, @@ -1133,7 +1137,7 @@ def _invoke_field_validators(self, *, error_store: ErrorStore, data, many: bool) 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, 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 3c659cb9f..df65492a1 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -248,7 +248,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.") @@ -259,7 +259,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: @@ -273,7 +273,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: @@ -327,7 +327,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() @@ -341,7 +341,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") @@ -368,7 +368,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() @@ -388,15 +388,15 @@ class BadSchema(Schema): bar = fields.String(data_key="Bar") @validates("foo", "bar") - def validate_string(self, data: str): - raise ValidationError(f"'{data}' is invalid.") + 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."], - "Bar": ["'data2' is invalid."], + "foo": ["'data' is invalid for foo."], + "Bar": ["'data2' is invalid for Bar."], } diff --git a/tests/test_schema.py b/tests/test_schema.py index 674b1a60d..b4fe1da71 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1740,11 +1740,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() From a30ae15dd56b398dfb896c00e12ff5ecff6bc28b Mon Sep 17 00:00:00 2001 From: Steven Loria Date: Sat, 18 Jan 2025 16:43:31 -0500 Subject: [PATCH 5/5] Update docs --- CHANGELOG.rst | 1 + docs/quickstart.rst | 6 +++--- docs/upgrading.rst | 38 +++++++++++++++++++++++++++++++++++ src/marshmallow/decorators.py | 2 +- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f5921fffe..8f6f200c1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,7 @@ Features: 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 9d9d7bae7..1a1cd0c5b 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -301,7 +301,7 @@ 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: @@ -321,10 +321,10 @@ It is sometimes convenient to write validators as methods. Use the `validates str: + def validate_names(self, value: str, data_key: str) -> None: if len(value) < 3: raise ValidationError("Too short") - return value + 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 28a9e95cb..3a481819f 100644 --- a/src/marshmallow/decorators.py +++ b/src/marshmallow/decorators.py @@ -89,7 +89,7 @@ def validates(*field_names: str) -> Callable[..., Any]: :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 method receives ``data_key`` as a keyword argument. + .. versionchanged:: 4.0.0 Decorated methods receive ``data_key`` as a keyword argument. """ return set_hook(None, VALIDATES, field_names=field_names)